Feature Categorization¶
Feature categorization is the first step in Pilz's algorithm. Each feature is binned into n_cat categories to enable multi-dimensional correlation analysis.
Why Categorization?¶
Before we can build correlation tables for feature combinations, we need discrete bins:
Two Types of Binning¶
The training path is cater() → feat_cater_fused(), which calls the native
pilz_rs.cater_cont/cater_cont_batch_arrow for a numerical feature and
pilz_rs.cater_cat for a categorical one. The legacy, test-only
feat_cater() helper still dispatches to the pure-Python
cont_cater_impl()/cat_cater_impl() implementations by feature type:
def feat_cater(self, feat, df_group, n_cat) -> CatCats | ContCats:
match feat.statistical:
case FeatureType.CATEGORIAL:
return self.cat_cater_impl(feat=feat, df_group=df_group, n_cat=n_cat)
case FeatureType.NUMERICAL:
return self.cont_cater_impl(feat=feat, df_group=df_group, n_cat=n_cat)
Numerical Features: Quantile Binning¶
For continuous values, cont_cater_impl() creates bins by interpolating
quantile boundaries from the cumulative group weights. Null and NaN rows are
excluded from the boundary computation and receive a dedicated "missing" bin;
when no rows remain, the missing bin becomes the only label:
def cont_cater_impl(self, feat, df_group, n_cat) -> ContCats:
is_float = df_group[feat.name].dtype.is_float()
has_nan = is_float and df_group[feat.name].is_nan().any()
has_null = df_group[feat.name].has_nulls() or has_nan
filter_expr = pl.col(feat.name).is_not_null()
if is_float:
filter_expr = filter_expr & pl.col(feat.name).is_not_nan()
df_sorted = df_group.filter(filter_expr)[
[feat.name, "weight"]
].sort(feat.name)
if df_sorted.height == 0:
return ContCats(
cuts=[],
labels=["0"],
feat_name=feat.name,
missing_label="1" if has_null else None,
)
cumulative_weights = df_sorted["weight"].cum_sum() / df_sorted["weight"].sum()
res = []
label = ["0"]
idx = 0
for i in range(1, n_cat):
quantile = i / n_cat
index = (cumulative_weights >= quantile).arg_true()[0]
# interpolate the boundary value between the neighbours
...
idx += 1
label.append(f"{idx}")
res.append(value)
return ContCats(
cuts=res,
labels=label,
feat_name=feat.name,
missing_label=str(len(label)) if has_null else None,
)
The bin boundaries are stored in the ContCats class, which uses pl.Series.cut() to apply them to new data:
class ContCats(CatsMixin):
cuts: list[float]
labels: list[str]
feat_name: str
missing_label: str | None = None
def cut(self, df: pl.DataFrame) -> pl.Series:
res = df[self.feat_name].cut(self.cuts, labels=self.labels).cast(pl.Utf8)
if self.missing_label is None:
return res
return res.fill_null(self.missing_label)
Categorical Features: Target Rate Grouping¶
For categorical values, cat_cater_impl() assigns each original category to a bin based on its weight and target rate:
- Large categories first: Categories with weight >=
1/(n_cat - i)of the remaining weight get their own bin; the remaining weights are renormalized - Small categories grouped: Remaining categories are sorted by target rate and grouped by cumulative-weight quantiles
def cat_cater_impl(self, feat, df_group, n_cat) -> CatCats:
has_null = df_group[feat.name].has_nulls()
df = (
df_group.with_columns(
pl.col(feat.name).cast(pl.Utf8).fill_null(MISSING_SENTINEL)
)
.group_by(feat.name)
.agg(pl.col("weight").sum(), pl.col("target_weight").sum())
.sort(pl.col("weight"), descending=True)
)
# Phase 1: large categories get their own bin
for i in range(n_cat):
if df.height == 0:
break
val = df.item(0, "weight")
if val < 1.0 / (n_cat - i):
break
cat = df.item(0, feat.name)
if cat == MISSING_SENTINEL:
missing_label = str(i)
else:
map[cat] = str(i)
df = df.tail(-1).with_columns(pl.col("weight").truediv((1.0 - val)))
# Phase 2: remaining categories grouped by target-rate quantiles
df = df.with_columns(
pl.col("target_weight").truediv(pl.col("weight")).alias("target_rate")
).sort("target_rate", descending=False)
df = df.with_columns(pl.col("weight").cum_sum().alias("cum_weight"))
labels = [str(i) for i in range(offset, n_cat)]
breaks = [i / len(labels) for i in range(1, len(labels))]
df = df.with_columns(
pl.col("cum_weight")
.cut(breaks=breaks, labels=labels, left_closed=True)
.alias("bin")
)
...
return CatCats(mapping=map, default=..., feat_name=feat.name, missing_label=...)
The mapping is stored in the CatCats class, which applies it using replace_strict:
class CatCats(CatsMixin):
mapping: dict[str | int, str]
default: str
feat_name: str
missing_label: str | None = None
def cut(self, df: pl.DataFrame) -> pl.Series:
if self.missing_label is None:
return df[self.feat_name].cast(pl.Utf8).replace_strict(
self.mapping, default=self.default
)
return (
df.with_columns(
pl.when(pl.col(self.feat_name).is_null())
.then(pl.lit(self.missing_label))
.otherwise(
pl.col(self.feat_name)
.cast(pl.Utf8)
.replace_strict(self.mapping, default=self.default)
)
.alias(self.feat_name)
)
.select(self.feat_name)
.to_series()
)
The Code Path¶
The entry point is cater(). It iterates over all features in the datacard:
the numerical features are categorized in one native batch call
(pilz_rs.cater_cont_batch_arrow, wrapped by cater_cont_batch), the
categorical features via pilz_rs.cater_cat (wrapped by
cater_cat_reps/feat_cater_fused). Every categorized feature is kept;
features that differentiate too poorly are flagged in feature_mask so the
split search never sees them:
def cater(self, train_df: TrainDataframes):
reps = list(train_df)
results = {}
cont_features = [f for f in self.dc.train_features
if f.statistical == FeatureType.NUMERICAL]
if cont_features:
results.update(self.cater_cont_batch(
features=cont_features, reps=reps, n_cat=self.settings.n_cat,
))
for feat in self.dc.train_features:
if feat.statistical == FeatureType.CATEGORIAL:
results[feat.name] = self.cater_cat_reps(
feat=feat, reps=reps, n_cat=self.settings.n_cat,
)
for feat in self.dc.train_features:
median_feature, rep_features = results[feat.name]
train_df.train_features.append(median_feature)
train_df.train_features_by_rep.append(rep_features)
train_df.feature_mask.append(not median_feature.is_diff_to_low())
Each categorized feature is scored via calc_diff(), delegated to the native
pilz_rs.score. The scoring method is selected with the score_method
training setting:
"diff_all_<exp>"— dimension-neutral sum0.5 * sum(|p - q| ** exp)."diff_dir_<exp>"(default"diff_dir_1.5") — dominant-side directional sum of|p - q| ** exp."lift_dir_<exp>"— dominant-side, evidence-weighted sum of the log-likelihood liftn * |ln(n / E)| ** exp(Ethe expected count under independence)."hellinger"— Hellinger distance0.5 * sum((sqrt(p) - sqrt(q)) ** 2).
With smoothing > 0 the proportions and counts used by all scoring methods
are Laplace-smoothed:
def calc_diff(self) -> float:
if self._score is None:
self._score = pilz_rs.score(self._corr, self.score_method)
return self._score
Features that don't differentiate well enough are filtered out by is_diff_to_low():
def is_diff_to_low(self, threshold: float = 0.90) -> bool:
max_wert = max(self._corr.max_proportion)
for proportion, proportion_right, max_proportion in zip(
self._corr.proportion,
self._corr.proportion_right,
self._corr.max_proportion,
):
if max_proportion == max_wert:
return min(proportion, proportion_right) > threshold
return False
The 2D Pair-Correlation Filter¶
The 1D check looks at one feature in isolation. The pair_corr_filter
training setting extends the same idea to feature combinations and runs in
every dimension stage (dim 2..=n_dims) of the split search: for every
evaluated candidate the joint correlation table is checked for redundant
features. For every candidate position the cells are grouped by the
remaining features and the share of each class inside the dominant bin of
that position is summed:
When BOTH classes put more than pair_corr_min_main (default 0.9) of their
mass into the dominant bins, one feature of the combination is redundant
given the rest — knowing the remaining features determines it for both
classes. The 1D-weakest feature of the candidate is masked immediately:
- the triggering candidate itself is excluded as a split,
- every later combination of the same node containing the masked feature is
skipped (without consuming
calcs_per_dimbudget), - combinations evaluated before the trigger keep their candidacy,
- the mask lives only in the current node — the next node starts fresh.
The check runs on the candidate's median-repetition table, so it costs
nothing extra: the table is computed for scoring anyway. With
n_dims: 1 no pair filtering happens, and the 1D-best feature can never be
masked (it is never the weakest member of a combination). The masking
decisions are surfaced on the CounterBatchResult and stored on the trainer
as Train._debug_masked_pairs for inspection; no log line is emitted.
Building Correlation Tables¶
After binning, Pilz builds the joint contingency table for a feature
combination natively with pilz_rs.corr_table, from the target/non-target
bin codes of the combined features (the raw series are not available on the
fused path — target_sr/non_target_sr are None):
class CombinedCategorizedFeature(CategorizedFeatureMixin):
def __init__(self, train_features, non_target_size, target_size, score_method="diff_dir_1.5", smoothing=0.0):
self._train_features = train_features
self.cut_list = [train.cuts for train in train_features]
self._corr = pilz_rs.corr_table(
[train._target_codes for train in train_features],
[train._non_target_codes for train in train_features],
[
getattr(train, "_n_labels", None) or len(train.cuts.all_labels)
for train in train_features
],
smoothing,
)
self._score = None
self._diff_df = None
Configuration¶
The n_cat parameter controls the granularity of binning:
Summary¶
| Feature Type | Binning Method | Key Function |
|---|---|---|
| Numerical | Quantile binning with cumulative-weight interpolation | cont_cater_impl() (legacy/test-only); native cater_cont on the fused path |
| Categorical | Weight-based own bins + target-rate quantiles | cat_cater_impl() (legacy/test-only); native cater_cat on the fused path |
| Scoring | diff_*, lift_dir_* or hellinger score of the correlation table |
calc_diff() |
| 1D Filtering | Masks features with insufficient differentiation | is_diff_to_low() |
| 2D Filtering | Masks the weakest feature of correlated combinations | counter_batch() in rust/src/lib.rs |
Next Steps¶
- Multi-Dimensional Splits — How these binned features are combined
- Splitting Nodes into Branches — How splits use the correlation tables
- How Pilz Works — Overview of the full algorithm