Training Internals¶
This chapter provides a deep dive into how Pilz training works under the hood. If you want to understand every detail of the algorithm, read on.
Architecture Overview¶
eval only] end DC --> TS TS --> M M --> DW DW --> P style M fill:#e0f0ff style C fill:#ccffcc style W fill:#ffff99 style R fill:#ffcccc
Data Flow¶
DuckDB is not part of the training path: the per-node sample is drawn in
Polars, and decision thresholds are derived on the fly from the stored leaf
counts. DuckDB is only used to score legacy cut-model leaves during
evaluation and for the multi-class label combination.
The Main Loop¶
The run() method iterates n times (the tree index) and trains one tree per
target value on every pass:
def run(self):
logger.info("start training")
SympyToSqlHelper.columns = self.dc.feature_names + [self.dc.target.feature_name]
for n in range(self.settings.n):
for cat in self.dc.target.values:
if self.pilz_exists(target=cat, n=n):
logger.info(f"tree {n} for label {cat} already exists")
continue
logger.info(f"train tree {n} for label {cat}")
train_res = Pilz(
spore=self.train_pilz(
target_filter=self.gen_target_filter(cat=cat),
path_filter=[],
),
target=cat,
)
self.save_pilz(pilz=train_res, n=n)
self.write_label_stats()
The train_pilz Function¶
This is the core recursive function that builds a single tree. It finds the
winning feature combination, merges its observed bin combinations into
branches via get_cell_branches(spore_diff) and recurses once per branch
(.<i>), plus the residual .n branch for the combinations never observed in
the count sample:
def _train_pilz_branches(self, target_filter, path_filter, depth, parent_features, train_df):
best_feat, winner_features = self._find_winner(train_df=train_df)
if best_feat is None:
return self.make_spore(path_filter=path_filter, depth=depth,
train_df=train_df, feature_names=parent_features)
branches, residual = best_feat.get_cell_branches(
spore_diff=self.settings.spore_diff
)
if not branches:
return self.make_spore(path_filter=path_filter, depth=depth,
train_df=train_df, feature_names=parent_features)
spores = []
for i, (branch_filter, _) in enumerate(branches):
spores += self.train_pilz(
target_filter=target_filter,
path_filter=path_filter + [branch_filter],
depth=_child_depth(str(i)), # "0", "1.0", ...
parent_features=winner_features,
)
if residual is not None and residual.combine is not TRUE:
spores += self.train_pilz(
target_filter=target_filter,
path_filter=path_filter + [residual],
depth=_child_depth("n"), # residual branch
parent_features=winner_features,
)
return spores
Each recursive call:
1. Reads a fresh balanced sample for this node via read_akt_train() (see Downsampling)
2. Stops if too few samples remain or max depth reached
3. Categorizes features via cater() (see Feature Categorization)
4. Finds the best split via _find_winner() (see Multi-Dimensional Splits)
5. Recurse on every branch of the winning split (see Splitting Nodes into Branches)
The cater Function¶
Categorizes all features into n_cat bins each. The numerical features go
through one native batch call (pilz_rs.cater_cont_batch_arrow, wrapped by
cater_cont_batch); the categorical features use the single-call path
(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:
def cater(self, train_df: TrainDataframes):
reps = list(train_df)
results = {}
cont_features = [
feat for feat in self.dc.train_features
if feat.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())
Features that don't differentiate well enough are excluded 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
Repetition handling (n_rep)¶
Each feature is categorized over n_rep independent random splits of the data
into count and group sets to reduce statistical fluctuation. Unlike the early
single-threaded implementation, Pilz no longer keeps a feat_rep/feat_reps
helper: the categorization and the per-repetition alignment are handled
natively.
TrainDataframes stores n_rep independent random splits. The group
frames carry the weight/target_weight columns and drive the binning; the
count frames supply the target/non-target bin codes that form the
correlation tables:
def __init__(self, target_df, non_target_df, frac_eval_cat, min_size, n_rep):
self.target_df_size = target_df.height
self.non_target_df_size = non_target_df.height
self.min_size = min_size
self.n_count_target, _ = self._calc_split(target_df.height, frac_eval_cat)
self.n_count_non_target, _ = self._calc_split(non_target_df.height, frac_eval_cat)
for _ in range(n_rep):
perm_target = np.random.permutation(target_df.height)
shuffled_target = target_df[perm_target]
self.target_df_count.append(shuffled_target.head(self.n_count_target))
target_group = shuffled_target.tail(-self.n_count_target)
perm_non_target = np.random.permutation(non_target_df.height)
shuffled_non_target = non_target_df[perm_non_target]
self.non_target_df_count.append(
shuffled_non_target.head(self.n_count_non_target)
)
non_target_group = shuffled_non_target.tail(-self.n_count_non_target)
w_target = 0.5 / max(1, target_group.height)
w_non_target = 0.5 / max(1, non_target_group.height)
group = pl.concat([target_group, non_target_group]).hstack(
[
pl.Series("weight", np.concatenate([
np.full(target_group.height, w_target),
np.full(non_target_group.height, w_non_target),
])),
pl.Series("target_weight", np.concatenate([
np.full(target_group.height, 2 * w_target),
np.zeros(non_target_group.height),
])),
]
)
self.df_group.append(group)
cater()categorizes every feature once per repetition. The numerical features use one native batch call (pilz_rs.cater_cont_batch_arrow), the categorical features thepilz_rs.cater_catpath; each repetition is scored and the median repetition per feature is selected.- The split search passes all
n_repstopilz_rs.counter_batch, which evaluates every combination once per repetition (only rows of the same repetition are combined — row alignment is preserved) and picks the median repetition for the winner.
The number of repetitions is controlled by the n_rep setting:
| Value | Effect |
|---|---|
| 1 | No repetition — fast but noisy |
| 5 | Default — good balance of speed and stability |
| 10+ | Very stable cuts, slower training |
The Split Search (_find_winner)¶
Finds the best feature or combination to split on. It scores every categorized
feature by calc_diff(), sorts them descending and takes the best usable one
as the baseline. For n_dims >= 2 the combination search runs natively in
pilz_rs.counter_batch: it evaluates the dimension stages 2..=n_dims one
after another, scores every candidate over all repetitions and returns the
winner plus the updated feature mask:
def _find_winner(self, train_df: TrainDataframes):
sorted_train_feats = sorted(
zip(
train_df.train_features,
train_df.train_features_by_rep,
train_df.feature_mask,
),
key=lambda x: x[0].calc_diff(),
reverse=True,
)
feature_mask = [usable for _, _, usable in sorted_train_feats]
...
res = pilz_rs.counter_batch(
target_codes, non_target_codes, label_counts,
n_features, n_reps, dim1_scores,
self.settings.n_dims, self.settings.calcs_per_dim,
self.settings.min_split_improvement, self.settings.score_method,
self.settings.smoothing, feature_mask,
self.settings.pair_corr_filter, self.settings.pair_corr_min_main,
self.settings.pair_corr_max_features,
)
...
return best_feat, winner_features
The legacy counter() method is a parity helper only: it delegates to
_find_winner() and derives the old three-way l/n/r filters from the
winner, and is no longer part of the training path.
The 2D Pair-Correlation Filter¶
In every dimension stage each evaluated candidate checks its joint
correlation table for redundant features: when both classes put more than
pair_corr_min_main (default 0.9) of their mass into the per-cell dominant
bin of a candidate position, the 1D-weakest feature of the candidate is
masked immediately. The triggering candidate is excluded and every later
combination of the node containing the masked feature is skipped — without
consuming calcs_per_dim budget, so the budget stretches further for the
meaningful combinations. The mask lives only in the current node; the next
node starts fresh. The masking decisions are returned on the
CounterBatchResult and retained on the trainer for inspection via
Train._debug_masked_pairs. See
Pair-Correlation Filter for the full algorithm
and Settings Reference
for the tuning parameters.
Leaf Creation¶
Recursion stops when not enough samples remain or max depth is reached:
def is_final_size(self) -> bool:
return (
self.target_df_size < self.min_size
or self.non_target_df_size < self.min_size
)
A leaf is created with its score. Fresh models store the path as structured
Cond objects (rendered to SQL only on demand via Spore.get_cuts()), never
as pre-rendered cut strings, and record the winning feature combination of
its parent node in feature_names:
def make_spore(self, path_filter, depth, train_df, feature_names=None):
score = train_df.score()
return [
Spore(
cond=[Cond.from_expr(fil.combine) for fil in path_filter],
score=score,
depth=depth,
feature_names=feature_names,
)
]
The score is the signed difference between target and non-target rows at the
leaf, (target_size - non_target_size) / n, scaled by a weakening factor so
that sparsely populated leaves contribute a smaller magnitude:
def score(self) -> float:
sum = self.non_target_df_size + self.target_df_size
if sum == 0:
return 0.0
if sum == 1:
factor = 0.5
else:
factor = 1.0 - math.log(sum) / sum
diff = self.target_df_size - self.non_target_df_size
return factor * diff / sum
A positive score means more target than non-target rows, 0.0 means a perfect balance. The magnitude grows with the number of rows in the leaf and only approaches 1.0 for large leaves.
Data Caching¶
Darkwing provides caching to avoid reloading data. The cached training frame is
randomly shuffled once at load time, so the first rows matching a per-node
filter are already a random sample (no per-query ORDER BY RANDOM()):
def get_cached_train_df(self) -> pl.DataFrame:
if self.df_train_cach is None:
self.df_train_cach = self.get_df_from_files(self.train_files).sample(
fraction=1.0, shuffle=True
)
return self.df_train_cach
Summary¶
| Component | File | Description |
|---|---|---|
run() |
train.py |
Main loop: for n trees, for each target |
train_pilz() |
train.py |
Recursive tree building (read → cater → find winner → recurse) |
cater() |
train.py |
Categorizes features into n_cat bins (numeric batch / categorical) |
feat_cater() |
train.py |
Legacy/test-only dispatcher to numerical or categorical binning |
_find_winner() |
train.py |
Finds the best split via the native counter_batch search |
counter() |
train.py |
Legacy parity helper; delegates to _find_winner() |
make_spore() |
train.py |
Creates a leaf with structured cond and score |
is_final_size() |
dataframes.py |
Stopping criterion for recursion |
score() |
dataframes.py |
Signed (target - non_target) / n at a leaf |
get_cached_train_df() |
darkwing.py |
Data caching layer (shuffles once at load) |
Next Steps¶
- SQL Rules for Deployment — Deploy models to production
- Settings Reference — All parameters
- Troubleshooting — Common issues