Titanic Survival Prediction using Iguanas#

This notebook demonstrates a complete end-to-end example of using Iguanas for rule-based classification on the Kaggle Titanic dataset.

The workflow includes:

  1. Generating candidate rules using XGBoost

  2. Filtering and selecting high-quality rules

  3. Combining rules using different strategies

  4. Analyzing the best ruleset

  5. Rule explanation, fairness auditing, and registry

[1]:
import numpy as np
import polars as pl
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_explanation import compute_counterfactual, compute_coverage_overlap, verbalize_rule
from iguanas.rule_fairness import compute_subgroup_metrics
from iguanas.rule_generation import rule_grid_search
from iguanas.rule_registry import RuleRegistry, filter_rule_pairs_by_overlap
from iguanas.rule_selection import filter_correlated_rules

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"]

Extract numeric columns for rule generation:

[3]:
num_columns = [col for col, dtype in X_train.schema.items() if dtype in [pl.Int64, pl.Float64]]
num_columns
[3]:
['Pclass', 'Age', 'SibSp', 'Parch', 'Fare']

1. Generate Candidate Rules#

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

[4]:
estimator = XGBClassifier(n_estimators=10, max_depth=4, eval_metric="logloss", random_state=0)
rules = rule_grid_search(
    estimator,
    X_train[num_columns].to_pandas(),
    y_train.to_pandas(),
    scale_pos_weights=np.logspace(-3, 3, 50),
)
rules_df = rules.unique("rule")
[5]:
rules_df.head(3)
[5]:
shape: (3, 4)
ruletreetransformationscale_pos_weight
stri64strf64
"(X["Fare"] >= 52.5542) & (X["F…1"Baseline"0.068665
"(X["Fare"] >= 10.5) & (X["Age"…7"Baseline"0.159986
"(X["Pclass"] < 3.0) & (X["Fare…2"Baseline"0.09103
[6]:
rules = rules_df.select("rule").to_series().to_list()
print(f"Number of unique rules: {len(rules)}")
Number of unique rules: 175

2. Filter 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.

[7]:
R = apply_rules(X_train[num_columns], rules)
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
)
[8]:
R_fixed = R[uncorrelated_rules].fill_null(False)

# Disjoint pairs: max_overlap=0.0
disjoint = filter_rule_pairs_by_overlap(R_fixed, max_overlap=0.0)

# Near-redundant pairs (high overlap): min_overlap=0.9
redundant = filter_rule_pairs_by_overlap(R_fixed, min_overlap=0.9)

print(f"Total pairs:            {len(uncorrelated_rules) * (len(uncorrelated_rules) - 1) // 2}")
print(f"Disjoint  (jaccard=0):  {len(disjoint)}")
print(f"Redundant (jaccard≥0.9): {len(redundant)}")
disjoint.head(3)
Total pairs:            820
Disjoint  (jaccard=0):  14
Redundant (jaccard≥0.9): 24
[8]:
shape: (3, 5)
rule_arule_bjaccardflagged_by_bothflagged_by_either
strstrf64i64i64
"(X["Age"] >= 16.0) & (X["Age"]…"(X["Age"] < 16.0) & (X["SibSp"…0.00161
"(X["Age"] >= 18.0) & (X["Age"]…"(X["Fare"] >= 11.1333) & (X["S…0.00160
"(X["Age"] >= 18.0) & (X["Age"]…"(X["Fare"] >= 10.5) & (X["SibS…0.00162

3. Combine Rules#

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

3.1 Cumulative Combination#

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

[9]:
num_rules = R.shape[1]
[10]:
num_rules = len(uncorrelated_rules)
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)
[10]:
shape: (3, 16)
ruleTPFPTNFNprecisionrecallaccuracyflagged(%)good_flagged(%)f0.25f0.5f1f1.5f2num_rules
stri64i64i64i64f64f64f64f64f64f64f64f64f64f64u32
"combined_rule_1"174874471520.6666670.5337420.72209330.34883716.2921350.6570410.6350360.5928450.5686270.5559111
"combined_rule_2"176914431500.6591760.5398770.71976731.04651217.0411990.6507180.6312770.5935920.5717140.5601531
"combined_rule_3"176914431500.6591760.5398770.71976731.04651217.0411990.6507180.6312770.5935920.5717140.5601531

3.2 Beam Search with f1 optimization#

Uses beam search to explore rule combinations up to a maximum number of rules.

[11]:
R_f1 = combine_rules_beam_search(R[uncorrelated_rules], y_train, metric="f1", max_rules=12)
M_f1 = compute_metrics(R_f1, y_train)
M_f1.head(3)
[11]:
shape: (3, 16)
ruleTPFPTNFNprecisionrecallaccuracyflagged(%)good_flagged(%)f0.25f0.5f1f1.5f2num_rules
stri64i64i64i64f64f64f64f64f64f64f64f64f64f64u32
"((X["Pclass"] < 3.0) & (X["Far…241156282600.6070530.8006640.70771353.72124535.6164380.6158120.6379040.6905440.7291130.7526555
"((X["Pclass"] < 3.0) & (X["Far…241156282600.6070530.8006640.70771353.72124535.6164380.6158120.6379040.6905440.7291130.7526556
"((X["Pclass"] < 3.0) & (X["Far…241156282600.6070530.8006640.70771353.72124535.6164380.6158120.6379040.6905440.7291130.7526556

3.2 Beam Search with f1 accuracy#

Uses beam search to explore rule combinations up to a maximum number of rules.

[12]:
R_beam = combine_rules_beam_search(R[uncorrelated_rules], y_train, metric="accuracy", max_rules=12)
M_beam = compute_metrics(R_beam, y_train)
M_beam.head(3)
[12]:
shape: (3, 16)
ruleTPFPTNFNprecisionrecallaccuracyflagged(%)good_flagged(%)f0.25f0.5f1f1.5f2num_rules
stri64i64i64i64f64f64f64f64f64f64f64f64f64f64u32
"((X["Pclass"] < 3.0) & (X["Far…2051004171110.6721310.6487340.74669936.61464619.342360.6707080.6673180.6602250.6557580.6532824
"((X["Pclass"] < 3.0) & (X["Far…2051004171110.6721310.6487340.74669936.61464619.342360.6707080.6673180.6602250.6557580.6532824
"((X["Pclass"] < 3.0) & (X["Far…2051004171110.6721310.6487340.74669936.61464619.342360.6707080.6673180.6602250.6557580.6532824

4. Analyze the Best Ruleset#

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

[13]:
for r in M_beam["rule"][0].split(" | "):
    print(r)
((X["Pclass"] < 3.0) & (X["Fare"] >= 13.7917) & (X["Age"] < 61.0))
((X["Fare"] >= 11.1333) & (X["SibSp"] < 3.0) & (X["Age"] < 18.0))
((X["Fare"] >= 52.5542) & (X["SibSp"] < 2.0) & (X["Age"] < 63.0))
((X["Fare"] >= 75.25))
[14]:
ruleset = M_beam["rule"][0]
report = generate_rule_performance_report(ruleset, X_train, y_train)
report.head(5)
[14]:
shape: (5, 17)
rule_indexruleTPFPTNFNprecisionrecallaccuracyflagged(%)good_flagged(%)f0.25f0.5f1f1.5f2num_rules
strstri64i64i64i64f64f64f64f64f64f64f64f64f64f64u32
"0""((X["Pclass"] < 3.0) & (X["Far…2051004171110.6721310.6487340.74669936.61464619.342360.6707080.6673180.6602250.6557580.6532824
"0.0""(X['Pclass'] < 3.0) & (X['Fare…174874471520.6666670.5337420.72209330.34883716.2921350.6570410.6350360.5928450.5686270.5559111
"0.1""(X['Fare'] >= 11.1333) & (X['S…50115042600.8196720.161290.6715157.3939392.1359220.6609640.4512640.2695420.2142390.192161
"0.2""(X['Fare'] >= 52.5542) & (X['S…89275192440.7672410.2672670.69169513.1968154.9450550.6911830.5583440.3964370.3342960.307321
"0.3""(X['Fare'] >= 75.25)"74235262680.7628870.2163740.67340110.8866444.1894350.6642030.5068490.337130.2775530.252561

5. Rule Explanation#

Three tools to understand individual rules and their predictions.

5.1 Verbalize Rules#

Convert rule expressions into plain-English sentences for stakeholder reporting:

[15]:
# Verbalize the rules by accuracy
selected_rules = M_beam["rule"][0][1:-1].split(") | (")
for rule in selected_rules:
    print(verbalize_rule(rule))
    print()
Pclass is less than 3.0 AND Fare is at least 13.7917 AND Age is less than 61.0

Fare is at least 11.1333 AND SibSp is less than 3.0 AND Age is less than 18.0

Fare is at least 52.5542 AND SibSp is less than 2.0 AND Age is less than 63.0

Fare is at least 75.25

5.2 Coverage Overlap#

Pairwise Jaccard similarity between the top uncorrelated rules. A high score means two rules flag nearly the same passengers — useful for spotting redundancy that correlation filtering may have missed:

[16]:
overlap = compute_coverage_overlap(R[selected_rules])
overlap.head(8)
[16]:
shape: (6, 5)
rule_arule_bjaccardflagged_by_bothflagged_by_either
strstrf64i64i64
"(X["Fare"] >= 52.5542) & (X["S…"(X["Fare"] >= 75.25)"0.60150480133
"(X["Pclass"] < 3.0) & (X["Fare…"(X["Fare"] >= 52.5542) & (X["S…0.417293111266
"(X["Pclass"] < 3.0) & (X["Fare…"(X["Fare"] >= 75.25)"0.31617686272
"(X["Pclass"] < 3.0) & (X["Fare…"(X["Fare"] >= 11.1333) & (X["S…0.11034532290
"(X["Fare"] >= 11.1333) & (X["S…"(X["Fare"] >= 52.5542) & (X["S…0.06626511166
"(X["Fare"] >= 11.1333) & (X["S…"(X["Fare"] >= 75.25)"0.0604039149

5.3 Counterfactual Explanation#

For a passenger flagged by the best individual rule, find the minimal feature changes that would un-flag them:

[17]:
X_num = X_train[num_columns]
best_rule = M.sort("accuracy", descending=True)["rule"][0]

# Pick the first passenger flagged by the best rule
flagged_idx = apply_rules(X_num, [best_rule])[best_rule].arg_true().to_list()
sample = X_num[flagged_idx[0]]

print(f"Rule:   {best_rule}")
print(f"\nPassenger features: {dict(zip(sample.columns, sample.row(0)))}")
print("\nMinimal changes to un-flag this passenger:")
for cf in compute_counterfactual(best_rule, sample):
    print(f"  {cf['feature']:10s}  {cf['current_value']:>10.4f}{cf['suggested_value']:.6f}{cf['abs_change']:.6f})")
Rule:   (X["Pclass"] < 3.0) & (X["Fare"] >= 13.7917) & (X["Age"] < 61.0)

Passenger features: {'Pclass': 1, 'Age': 38.0, 'SibSp': 1, 'Parch': 0, 'Fare': 71.2833}

Minimal changes to un-flag this passenger:
  Pclass          1.0000  →  3.000001  (Δ 2.000001)
  Age            38.0000  →  61.000001  (Δ 23.000001)
  Fare           71.2833  →  13.791699  (Δ 57.491601)

6. Fairness — Metrics by Passenger Class#

compute_subgroup_metrics breaks down each rule’s precision, recall, and F1 per subgroup. Here we audit across the three passenger classes to detect disparate impact:

[18]:
# Use the top 3 uncorrelated rules and audit across Pclass (1st / 2nd / 3rd class)
group = X_train["Pclass"].cast(pl.String).rename("Pclass")

subgroup_df = compute_subgroup_metrics(
    R[uncorrelated_rules[:3]],
    y_train,
    group_col=group,
)
subgroup_df.select(["group", "group_size", "rule", "precision", "recall", "f1"])
[18]:
shape: (9, 6)
groupgroup_sizeruleprecisionrecallf1
stri32strf64f64f64
"1"216"(X["Pclass"] < 3.0) & (X["Fare…0.7083330.975410.82069
"1"216"(X["Pclass"] < 3.0) & (X["Fare…0.6954020.9918030.817568
"1"216"(X["Pclass"] < 3.0) & (X["Fare…0.8023260.5609760.660287
"2"184"(X["Pclass"] < 3.0) & (X["Fare…0.5913980.6470590.617978
"2"184"(X["Pclass"] < 3.0) & (X["Fare…0.5913980.6470590.617978
"2"184"(X["Pclass"] < 3.0) & (X["Fare…0.6329110.5882350.609756
"3"491"(X["Pclass"] < 3.0) & (X["Fare…NaN0.0NaN
"3"491"(X["Pclass"] < 3.0) & (X["Fare…NaN0.0NaN
"3"491"(X["Pclass"] < 3.0) & (X["Fare…NaN0.0NaN

7. Rule Registry#

RuleRegistry lets you version-control rulesets across experiments and compare their metrics side by side.

7.1 Save Snapshots#

[19]:
registry = RuleRegistry()

# Save beam-search with f1 opt and beam-search with acc opt rulesets with their metrics
registry.save(
    "beam_f1",
    rules=M_f1["rule"].to_list(),
    metrics=M_f1,
    metadata={"strategy": "beam_search", "metric": "f1"},
)
registry.save(
    "beam_acc",
    rules=M_beam["rule"].to_list(),
    metrics=M_beam,
    metadata={"strategy": "beam_search", "max_rules": 12, "metric": "accuracy"},
)

print("Saved snapshots:", registry.list())
print("\nBeam snapshot metadata:", registry.load("beam_acc")["metadata"])
Saved snapshots: ['beam_acc', 'beam_f1']

Beam snapshot metadata: {'strategy': 'beam_search', 'max_rules': 12, 'metric': 'accuracy'}

7.2 Compare Snapshots#

compare joins both snapshots on rule name and suffixes each metric column with the snapshot name — making it easy to spot regressions or improvements:

[20]:
# registry.compare() joins on the rule column — it works when the same rules
# exist in both snapshots (e.g. train vs test evaluation of the same ruleset).
# Here beam_f1 and beam_acc produce completely different combined expressions,
# so a join would return all nulls.  Build a direct head-to-head summary instead.

metrics_cols = ["precision", "recall", "accuracy", "f1"]

comparison = pl.concat([
    registry.load(name)["metrics"]
    .sort("f1", descending=True)
    .head(1)
    .select(metrics_cols)
    .with_columns(pl.lit(name).alias("strategy"))
    for name in registry.list()
]).select(["strategy"] + metrics_cols)

comparison
[20]:
shape: (2, 5)
strategyprecisionrecallaccuracyf1
strf64f64f64f64
"beam_acc"0.671010.6518990.7466990.661316
"beam_f1"0.6070530.8006640.7077130.690544

7.3 Filter Rule Pairs by Overlap#

filter_rule_pairs_by_overlap returns pairs whose Jaccard similarity falls within [min_overlap, max_overlap]. Both bounds are inclusive, making it easy to query any region of the overlap spectrum:

Call

Returns

filter_rule_pairs_by_overlap(R)

All pairs (defaults: 0.0 – 1.0)

filter_rule_pairs_by_overlap(R, max_overlap=0.0)

Only disjoint pairs (never co-fire)

filter_rule_pairs_by_overlap(R, min_overlap=0.9)

Only near-redundant pairs

filter_rule_pairs_by_overlap(R, min_overlap=0.2, max_overlap=0.6)

Mid-range overlap only

[21]:
# The Titanic dataset has ~177 missing Age values.
# Rules that reference Age evaluate to null for those rows.
# null -> NaN after cast, and np.corrcoef propagates NaN for any pair
# where either rule has a missing value.
# Fix: fill_null(False) — a rule that cannot be evaluated does not fire.

arr = R[uncorrelated_rules].fill_null(False).cast(pl.Float64).to_numpy()  # (891, 41)
corr_matrix = np.corrcoef(arr.T)                                           # (41, 41)

labels = [f"r{i}" for i in range(len(uncorrelated_rules))]
pl.DataFrame(corr_matrix, schema=labels).with_row_index("rule").head(10)
[21]:
shape: (10, 42)
ruler0r1r2r3r4r5r6r7r8r9r10r11r12r13r14r15r16r17r18r19r20r21r22r23r24r25r26r27r28r29r30r31r32r33r34r35r36r37r38r39r40
u32f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64
01.00.9839810.7406680.6849590.5980920.5800430.586090.6175530.5644320.5951410.5951410.5385040.5809810.567850.4778390.5492940.5014510.728150.6026540.5014510.6217590.6217590.4913040.53360.53360.4559390.1379960.1303150.6054060.5154250.5207810.4211880.6472720.6472720.4248820.4285540.3767640.5532240.3436680.0873460.511071
10.9839811.00.7288030.6739870.5885110.5707510.5767010.6040680.5623240.5923040.5923040.5420240.57140.5587530.469870.5404950.4931140.7430580.6246870.4931140.6117990.6117990.4834330.5249040.5249040.4635430.1331060.1254060.6016830.5067310.5124380.4144410.657810.657810.4180760.4216890.3691550.5626840.3371220.0829670.502885
20.7406680.7288031.00.7291020.5575920.5715120.5725740.742680.3393030.3372020.3372020.2999430.5432680.5695280.4369370.539360.4382190.6383760.3101320.4382190.8394570.8394570.4723680.4106490.4106490.1765920.2025480.1953730.5078470.3969230.5497140.5343360.4794140.4794140.3806090.2855450.427050.4186490.3869870.1384510.339599
30.6849590.6739870.7291021.00.8369050.8375490.8556570.6868190.5612960.5976770.5976770.5502780.8190290.8290270.6846750.8019370.7294810.4508350.5381360.7294810.5110850.5110850.7172750.4246180.4246180.4511340.1573960.1512040.4036340.4111570.5887810.614910.4433550.4433550.6083540.6138020.4218480.3741740.3910970.0988050.377378
40.5980920.5885110.5575920.8369051.00.9698220.9799320.5702620.6468730.6699580.6699580.6218650.9805130.9494350.7755610.9184110.7831370.3548950.6805230.7831370.3214510.3214510.8214520.2580070.2580070.5532780.0547030.0505210.2603050.2481840.5930210.7042190.3871290.3871290.6709280.7165340.2845940.3225380.2484-0.0169360.47107
50.5800430.5707510.5715120.8375490.9698221.00.979310.5740830.6291210.6446960.6446960.5982830.9509220.9789790.7541620.9469890.7749190.3499010.6599860.7749190.3371350.3371350.811270.2276650.2276650.5293130.0208550.0172760.2473370.2185240.6023050.712570.3754460.3754460.6921180.6987450.2653910.3117250.221974-0.0543460.465684
60.586090.5767010.5725740.8556570.9799320.979311.00.5876820.6417950.6564290.6564290.6093050.9608360.9688780.7689580.9372180.7886820.3491910.6668670.7886820.3317920.3317920.8382740.2451190.2451190.5446420.0592220.0550410.2609580.2356250.6066190.718640.379360.379360.6715180.717950.2936840.3153510.256156-0.0136730.459825
70.6175530.6040680.742680.6868190.5702620.5740830.5876821.00.3459250.3700830.3700830.3237170.5826050.5693920.4625550.5507860.4948270.7256640.2993970.4948270.6234480.6234480.4926380.3936680.3936680.267080.1485970.1600370.4053870.3779140.5221950.4223320.6257680.6257680.4163330.4200860.5445070.5317650.4548890.0880840.213032
80.5644320.5623240.3393030.5612960.6468730.6291210.6417950.3459251.00.9532870.9532870.8922760.6324480.603370.8502630.5742540.8008250.3441430.6216570.8008250.0578820.0578820.5273390.3089420.3089420.7214030.0403910.0364050.3669230.2982190.3351640.7007250.3890590.3890590.6544410.6479240.2074270.3444670.184533-0.0177370.243521
90.5951410.5923040.3372020.5976770.6699580.6446960.6564290.3700830.9532871.01.00.9360.654950.620880.8105450.5944420.8462140.3418940.6347550.8462140.0430090.0430090.5551120.3382170.3382170.7578230.0302750.0262730.4280210.3267270.3128870.6679920.4081240.4081240.6738510.6796740.2614520.3418910.216693-0.0254710.224585

5. Generate Predictions on Test Data#

Apply the best ruleset to the test data and create a submission file:

[22]:
X_test = pl.read_csv("../../../../../kaggle/titanic/test.csv")
y_pred = eval(ruleset.replace("X", "X_test"))
[23]:
# Create submission file (Kaggle leaderboard score: 0.61004)
# pl.DataFrame({"PassengerId": X_test["PassengerId"], "Survived": y_pred}).with_columns(
#     pl.col("Survived").cast(pl.Int64)
# ).write_csv("submission_titanic.csv")