Titanic Survival Prediction using Iguanas with Feature Engineering and ONNX file formating.#

This notebook demonstrates a complete end-to-end example of using Iguanas for rule-based classification on the Kaggle Titanic dataset, with advanced feature engineering using the Gators library.

The workflow includes:

  1. Loading and exploring the data

  2. Feature engineering using Gators (new columns, transformations, encoding)

  3. Generating candidate rules using XGBoost

  4. Filtering and selecting high-quality rules

  5. Combining rules using different strategies

  6. Generating predictions for submission

1. Import Libraries#

[1]:
import numpy as np
import polars as pl
from gators.data_cleaning import CastColumns, DropColumns, RenameColumns
from gators.discretizers import CustomDiscretizer
from gators.encoders import RareCategoryEncoder, WOEEncoder
from gators.feature_generation import ConditionFeatures, IsNull, MathFeatures, ScalarMathFeatures
from gators.feature_generation_str import (
    ExtractSubstring,
    SplitExtract,
)
from gators.imputers import NumericImputer, StringImputer
from gators.pipeline import Pipeline
from xgboost import XGBClassifier

from iguanas.metrics import compute_metrics
from iguanas.rule_analysis import generate_rule_performance_report
from iguanas.rule_combination import (
    combine_rules_beam_search,
    combine_rules_cumulative,
    combine_rules_greedy,
)
from iguanas.rule_evaluation import apply_rules
from iguanas.rule_generation import rule_grid_search
from iguanas.rule_selection import filter_correlated_rules

2. Load and Prepare Data#

Load the Titanic training data and separate features from the target variable (Survived).

[2]:
train = pl.read_csv("../../../../../kaggle/titanic/train.csv").drop("PassengerId")
X_train = train.drop("Survived")
y_train = train["Survived"]

3. Feature Engineering with Gators#

Build a comprehensive feature engineering pipeline using the Gators library. This pipeline will:

  • Create missing value indicators for Age and Cabin

  • Extract string features (name titles, cabin deck, ticket length)

  • Calculate family size and fare per person

  • Create categorical bins for age

  • Generate feature interactions

  • Apply Weight of Evidence (WOE) encoding for all categorical variables

Key transformations in this pipeline:

  1. Missing value handling: Create indicators for missing Age/Cabin, then impute

  2. Feature extraction: Extract passenger titles from names, cabin deck letters, ticket lengths

  3. Feature creation: Calculate family size, fare per person, traveling alone indicator

  4. Discretization: Convert continuous Age into categorical bins

  5. Interactions: Create combinations of Pclass, Age, CabinDeck, and Embarked

  6. Encoding: Apply WOE encoding to convert all categorical features to numeric values

[3]:
# Define the feature engineering pipeline
steps = [
    # Create missing value indicators
    ("IsNull", IsNull(subset=["Age", "Cabin"])),
    # String feature engineering
    ("SplitExtractName", SplitExtract(subset=["Name"], by=", ", n=1)),
    ("SplitExtractTitle", SplitExtract(subset=["Name__split_,__1"], by=".", n=0)),
    # Calculate family size (SibSp + Parch + 1)
    (
        "MathFeatures",
        MathFeatures(groups=[["SibSp", "Parch"]], func=["sum"], new_column_names=["Dummy"]),
    ),
    (
        "ScalarMathFeatures",
        ScalarMathFeatures(
            operations=[{"column": "Dummy_sum", "op": "+", "scalar": 1}],
            new_column_names=["FamilySize"],
        ),
    ),
    # Rename for clarity
    (
        "RenameColumns",
        RenameColumns(
            column_mapping={
                "Name__split_,__1__split_._0": "Title",
                # "Cabin__start0_end1": "CabinDeck",
            }
        ),
    ),
    # Handle rare categories (group infrequent values)
    ("RareCategoryEncoder", RareCategoryEncoder(min_count=0.01)),
    # Calculate fare per person
    (
        "MathFeatures2",
        MathFeatures(
            groups=[["Fare", "FamilySize"]], func=["div"], new_column_names=["FarePerPerson"]
        ),
    ),
    # Create 'traveling alone' indicator
    (
        "ConditionFeatures",
        ConditionFeatures(
            conditions=[{"column": "FamilySize", "op": ">", "value": 1}],
            new_column_names=["IsAlone"],
        ),
    ),
    # Drop raw columns no longer needed
    ("DropColumns", DropColumns(subset=["Cabin", "Ticket", "Dummy_sum"])),
    # Impute missing values
    ("NumericImputer", NumericImputer(strategy="mean")),
    ("StringImputer", StringImputer(strategy="constant", value="MISSING")),
    # as_numerics=True outputs float bin indices, required for ONNX export
    ("CustomDiscretizer", CustomDiscretizer(bins={"Age": [0, 12, 18, 35, 60, 100]}, inplace=True, as_numerics=True)),
    # Apply Weight of Evidence encoding (converts all categorical features to numeric)
    ("WOEEncoder", WOEEncoder()),
]

# Build and fit the pipeline
pipe = Pipeline(steps=steps, verbose=True)
X_train_transformed = pipe.fit_transform(X_train, y_train)

print(f"\nOriginal features: {X_train.shape[1]}")
print(f"Engineered features: {X_train_transformed.shape[1]}")
[Pipeline] fit+transform   1/14 · IsNull  |  in: rows=891  cols=10  nulls=866  →  out: rows=891  cols=12  nulls=866  (0.001s)
[Pipeline] fit+transform   2/14 · SplitExtractName  |  in: rows=891  cols=12  nulls=866  →  out: rows=891  cols=12  nulls=866  (0.001s)
[Pipeline] fit+transform   3/14 · SplitExtractTitle  |  in: rows=891  cols=12  nulls=866  →  out: rows=891  cols=12  nulls=866  (0.001s)
[Pipeline] fit+transform   4/14 · MathFeatures  |  in: rows=891  cols=12  nulls=866  →  out: rows=891  cols=13  nulls=866  (0.000s)
[Pipeline] fit+transform   5/14 · ScalarMathFeatures  |  in: rows=891  cols=13  nulls=866  →  out: rows=891  cols=14  nulls=866  (0.000s)
[Pipeline] fit+transform   6/14 · RenameColumns  |  in: rows=891  cols=14  nulls=866  →  out: rows=891  cols=14  nulls=866  (0.000s)
[Pipeline] fit+transform   7/14 · RareCategoryEncoder  |  in: rows=891  cols=14  nulls=866  →  out: rows=891  cols=14  nulls=864  (0.003s)
[Pipeline] fit+transform   8/14 · MathFeatures2  |  in: rows=891  cols=14  nulls=864  →  out: rows=891  cols=15  nulls=864  (0.000s)
[Pipeline] fit+transform   9/14 · ConditionFeatures  |  in: rows=891  cols=15  nulls=864  →  out: rows=891  cols=16  nulls=864  (0.000s)
[Pipeline] fit+transform   10/14 · DropColumns  |  in: rows=891  cols=16  nulls=864  →  out: rows=891  cols=13  nulls=177  (0.000s)
[Pipeline] fit+transform   11/14 · NumericImputer  |  in: rows=891  cols=13  nulls=177  →  out: rows=891  cols=13  nulls=0  (0.001s)
[Pipeline] fit+transform   12/14 · StringImputer  |  in: rows=891  cols=13  nulls=0  →  out: rows=891  cols=13  nulls=0  (0.000s)
[Pipeline] fit+transform   13/14 · CustomDiscretizer  |  in: rows=891  cols=13  nulls=0  →  out: rows=891  cols=13  nulls=0  (0.000s)
[Pipeline] fit+transform   14/14 · WOEEncoder  |  in: rows=891  cols=13  nulls=0  →  out: rows=891  cols=13  nulls=0  (0.004s)

Original features: 10
Engineered features: 13

4. Generate Candidate Rules#

Use XGBoost-based grid search to generate candidate rules from the engineered features. The rule_grid_search_parallel_scales function trains models with different scale_pos_weight values and extracts rules from the decision trees.

[4]:
estimator = XGBClassifier(n_estimators=100, max_depth=4, eval_metric="logloss", random_state=0)
rules = rule_grid_search(
    estimator, X_train_transformed, y_train, scale_pos_weights=np.logspace(0, 3, 50)
)
[5]:
print(f"Number of rules generated: {len(rules)}")
Number of rules generated: 1635

5. Select High-Quality Rules#

Apply the generated rules to the training data, compute performance metrics, and filter based on:

  • Minimum precision (> 0.15)

  • Minimum recall (> 0.15)

  • Maximum correlation between rules (< 0.8)

This ensures we keep only the most useful and diverse rules.

[6]:
R = apply_rules(X_train_transformed, rules.select("rule").to_series().to_list())
M = compute_metrics(R, y_train)
M = M.filter((pl.col("precision") > 0.15) & (pl.col("recall") > 0.15)).sort(
    "accuracy", descending=True
)
importance = dict(zip(M["rule"], M["f0.5"], strict=False))
uncorrelated_rules = filter_correlated_rules(
    R[M["rule"].to_list()], importance=importance, max_corr=0.8
)
[7]:
num_rules = len(uncorrelated_rules)
print(f"Number of selected rules: {num_rules}")
Number of selected rules: 26

6. Combine Rules#

Test different rule combination strategies to find the best performing ruleset.

6.1 Cumulative Combination#

Combines rules cumulatively (rule1 OR rule2 OR … OR ruleN):

[8]:
R_combined = combine_rules_cumulative(
    R[uncorrelated_rules], output_names=[f"combined_rule_{i}" for i in range(1, num_rules + 1)]
)
M_combined = compute_metrics(R_combined, y_train).sort("accuracy", descending=True)
M_combined.head(3)
[8]:
shape: (3, 17)
ruleTPFPTNFNprecisionrecallaccuracyflagged(%)good_flagged(%)f0.25f0.5f1f1.5f2mccnum_rules
stri64i64i64i64f64f64f64f64f64f64f64f64f64f64f64u32
"combined_rule_4"25257492900.8155340.7368420.83501734.68013510.3825140.8104430.7984790.7741940.7593880.7513420.6468061
"combined_rule_5"25257492900.8155340.7368420.83501734.68013510.3825140.8104430.7984790.7741940.7593880.7513420.6468061
"combined_rule_6"25257492900.8155340.7368420.83501734.68013510.3825140.8104430.7984790.7741940.7593880.7513420.6468061

7. Analyze the Best Ruleset#

Generate a detailed report for the best performing ruleset from brute force combination:

[11]:
for r in M_beam["rule"][0].split(" | "):
    print(r)
((X["FamilySize"] < 5.0) & (X["Title"] >= 0.25029))
((X["FarePerPerson_div"] >= 11.5) & (X["Sex"] >= 1.52977))
((X["Cabin__is_null"] < 1.0) & (X["Fare"] >= 7.775) & (X["Fare"] < 151.55) & (X["Age"] < 4.0))
((X["Title"] >= 0.77539) & (X["Pclass"] < 3.0))
[12]:
ruleset = M_beam["rule"][0]
print(f"Selected ruleset: {ruleset}")
report = generate_rule_performance_report(ruleset, X_train_transformed, y_train)
report
Selected ruleset: ((X["FamilySize"] < 5.0) & (X["Title"] >= 0.25029)) | ((X["FarePerPerson_div"] >= 11.5) & (X["Sex"] >= 1.52977)) | ((X["Cabin__is_null"] < 1.0) & (X["Fare"] >= 7.775) & (X["Fare"] < 151.55) & (X["Age"] < 4.0)) | ((X["Title"] >= 0.77539) & (X["Pclass"] < 3.0))
[12]:
shape: (15, 18)
rule_indexruleTPFPTNFNprecisionrecallaccuracyflagged(%)good_flagged(%)f0.25f0.5f1f1.5f2mccnum_rules
strstri64i64i64i64f64f64f64f64f64f64f64f64f64f64f64u32
"0""((X["FamilySize"] < 5.0) & (X[…27071478720.7917890.7894740.83950638.27160512.9326050.7916520.7913250.790630.7901850.7899360.6605144
"0.0""(X['FamilySize'] < 5.0) & (X['…239574921030.8074320.698830.82042633.221110.3825140.8001180.7830930.7492160.7290.7181490.614351
"0.1""(X['FarePerPerson_div'] >= 11.…13275422100.949640.3859650.75645315.6004491.2750460.8745130.7349670.5488570.4722070.4379560.5001971
"0.2""(X['Cabin__is_null'] < 1.0) & …72165332700.8181820.2105260.6790129.8765432.914390.6994290.5187320.3348840.2728860.2472530.2956621
"0.3""(X['Title'] >= 0.77539) & (X['…16695401760.9485710.485380.79236819.6408531.6393440.8981540.7965450.6421660.5712020.5379130.5740961
………………………………………………
"0.2.1""(X['Fare'] >= 7.775)"313438111290.4167780.9152050.4758784.28731879.7814210.4305710.4677230.5727360.6690230.7385560.1568731
"0.2.2""(X['Fare'] < 151.55)"3225409200.373550.941520.37149396.7452398.3606560.3872930.4248020.5348840.6414340.721973-0.1153411
"0.2.3""(X['Age'] < 4.0)"259415134830.3842730.757310.44107775.64534275.5919850.395740.4262670.5098430.5831310.6341820.0015751
"0.3.0""(X['Title'] >= 0.77539)"24998451930.7175790.728070.78563438.94500617.8506380.7181880.7196530.7227870.724810.7259480.5480921
"0.3.1""(X['Pclass'] < 3.0)"2231773721190.55750.6520470.66778944.89337832.2404370.5622960.574150.6010780.6197090.6306560.3223081
[13]:
ruleset
[13]:
'((X["FamilySize"] < 5.0) & (X["Title"] >= 0.25029)) | ((X["FarePerPerson_div"] >= 11.5) & (X["Sex"] >= 1.52977)) | ((X["Cabin__is_null"] < 1.0) & (X["Fare"] >= 7.775) & (X["Fare"] < 151.55) & (X["Age"] < 4.0)) | ((X["Title"] >= 0.77539) & (X["Pclass"] < 3.0))'

8. Generate Predictions on Test Data#

Apply the same preprocessing pipeline to the test data, then use the best ruleset to generate predictions:

[14]:
X_test = pl.read_csv("../../../../../kaggle/titanic/test.csv")
X_test_transformed = pipe.transform(X_test)
y_pred = eval(ruleset.replace("X", "X_test_transformed"))
[Pipeline] transform   1/14 · IsNull  |  in: rows=418  cols=11  nulls=414  →  out: rows=418  cols=13  nulls=414  (0.000s)
[Pipeline] transform   2/14 · SplitExtractName  |  in: rows=418  cols=13  nulls=414  →  out: rows=418  cols=13  nulls=414  (0.001s)
[Pipeline] transform   3/14 · SplitExtractTitle  |  in: rows=418  cols=13  nulls=414  →  out: rows=418  cols=13  nulls=414  (0.001s)
[Pipeline] transform   4/14 · MathFeatures  |  in: rows=418  cols=13  nulls=414  →  out: rows=418  cols=14  nulls=414  (0.000s)
[Pipeline] transform   5/14 · ScalarMathFeatures  |  in: rows=418  cols=14  nulls=414  →  out: rows=418  cols=15  nulls=414  (0.000s)
[Pipeline] transform   6/14 · RenameColumns  |  in: rows=418  cols=15  nulls=414  →  out: rows=418  cols=15  nulls=414  (0.000s)
[Pipeline] transform   7/14 · RareCategoryEncoder  |  in: rows=418  cols=15  nulls=414  →  out: rows=418  cols=15  nulls=414  (0.001s)
[Pipeline] transform   8/14 · MathFeatures2  |  in: rows=418  cols=15  nulls=414  →  out: rows=418  cols=16  nulls=415  (0.000s)
[Pipeline] transform   9/14 · ConditionFeatures  |  in: rows=418  cols=16  nulls=415  →  out: rows=418  cols=17  nulls=415  (0.000s)
[Pipeline] transform   10/14 · DropColumns  |  in: rows=418  cols=17  nulls=415  →  out: rows=418  cols=14  nulls=88  (0.000s)
[Pipeline] transform   11/14 · NumericImputer  |  in: rows=418  cols=14  nulls=88  →  out: rows=418  cols=14  nulls=0  (0.001s)
[Pipeline] transform   12/14 · StringImputer  |  in: rows=418  cols=14  nulls=0  →  out: rows=418  cols=14  nulls=0  (0.000s)
[Pipeline] transform   13/14 · CustomDiscretizer  |  in: rows=418  cols=14  nulls=0  →  out: rows=418  cols=14  nulls=0  (0.000s)
[Pipeline] transform   14/14 · WOEEncoder  |  in: rows=418  cols=14  nulls=0  →  out: rows=418  cols=14  nulls=0  (0.001s)
[15]:
# Create submission file (Kaggle leaderboard score: 0.78)
# Note: +25% better than without feature engineering (0.60)
pl.DataFrame({"PassengerId": X_test["PassengerId"], "Survived": y_pred}).with_columns(
    pl.col("Survived").cast(pl.Int64)
).write_csv("submission_titanic.csv")
[ ]:

9. ONNX Export — Verify End-to-End Predictions Match#

Build two ONNX models and stitch them into a single graph:

Step

Tool

ONNX function

Feature engineering

Gators Pipeline

pipeline_to_onnx

Rule scoring

Iguanas ruleset

rules_to_onnx

End-to-end

combined

pipeline_to_scoring_onnx

pipeline_to_scoring_onnx inserts a reshape bridge between the two models: it Unsqueezes each selected preprocessing output column [N] → [N, 1] and Concatenates them into the [N, num_features] matrix that the Iguanas rules model expects as its input X.

[16]:
import onnxruntime as ort
from onnx import TensorProto
from gators.onnx_converters import pipeline_to_onnx, pipeline_to_scoring_onnx

from iguanas.onnx_converter import rules_to_onnx

# --- 1. Iguanas ruleset → ONNX ---
rules_onnx = rules_to_onnx(ruleset)

feature_map = {p.key: p.value for p in rules_onnx.metadata_props}
feature_cols = [feature_map[f"feature_{i}"] for i in range(len(feature_map))]
print(f"Rule features ({len(feature_cols)}): {feature_cols}")

# Ensure pipeline carries the fitted flag (fit_transform sets it from gators ≥ next release)
pipe._is_fitted = True
pipe._input_columns = list(X_train.columns)
pipe._input_dtypes = dict(zip(X_train.columns, X_train.dtypes))

# --- 2. Gators pipeline + Iguanas rules → single end-to-end ONNX model ---
#    pipeline_to_scoring_onnx bridges the preprocessing outputs into the
#    [N, num_features] matrix that rules_onnx expects as its input 'X'.
combined_onnx = pipeline_to_scoring_onnx(pipe, rules_onnx, feature_cols)
print(f"\nCombined ONNX inputs  : {[i.name for i in combined_onnx.graph.input]}")
print(f"Combined ONNX outputs : {[o.name for o in combined_onnx.graph.output]}")
Rule features (8): ['FamilySize', 'Title', 'FarePerPerson_div', 'Sex', 'Cabin__is_null', 'Fare', 'Age', 'Pclass']

Combined ONNX inputs  : ['Pclass__in', 'Name__in', 'Sex__in', 'Age__in', 'SibSp__in', 'Parch__in', 'Ticket__in', 'Fare__in', 'Cabin__in', 'Embarked__in']
Combined ONNX outputs : ['prediction']
[17]:
# --- 3. Build raw input feeds from X_train ---
#    Each pipeline input tensor is named '{col}__in'.
#    Feed dtype must match each input's declared ONNX elem_type exactly.
feeds = {}
for inp in combined_onnx.graph.input:
    col = inp.name.removesuffix("__in")
    s = X_train[col]
    elem_type = inp.type.tensor_type.elem_type
    if elem_type == TensorProto.STRING:
        feeds[inp.name] = s.fill_null("").to_numpy(allow_copy=True)
    elif elem_type == TensorProto.INT64:
        feeds[inp.name] = s.to_numpy(allow_copy=True).astype("int64")
    elif elem_type == TensorProto.DOUBLE:
        feeds[inp.name] = s.to_numpy(allow_copy=True).astype("float64")
    else:
        feeds[inp.name] = s.to_numpy(allow_copy=True).astype("float32")

sess = ort.InferenceSession(combined_onnx.SerializeToString())
onnx_pred = sess.run(None, feeds)[0]  # int64 [N]

# --- 4. Reference predictions via Polars on already-transformed data ---
ref_pred = (
    apply_rules(X_train_transformed, [ruleset])[ruleset]
    .fill_null(False)
    .cast(pl.Int64)
    .to_numpy()
)

match = (onnx_pred == ref_pred).all()
print(f"Predictions match : {match}")
print(f"ONNX   positives  : {int(onnx_pred.sum())} / {len(onnx_pred)}")
print(f"Polars positives  : {int(ref_pred.sum())} / {len(ref_pred)}")
assert match, "ONNX and Polars predictions differ!"
Predictions match : True
ONNX   positives  : 341 / 891
Polars positives  : 341 / 891
[ ]: