Skip to content

Write your own pre-split filter

When you need it

Use a custom pre-split filter when a fixed eligibility rule must run before the holdout and CV split, and the admitted built-in pre-split steps cannot express it. A filter keeps or removes whole rows. It learns nothing and changes no values. Learned transformations belong in config/preprocessing.yml; see custom steps for those.

A generated project already ships three filter factories in src/features/pre_split.py: pre_split.minimum_completeness, pre_split.value_range and pre_split.allowed_values. Their parameters are in the ready functions catalog. Select the factory with custom: in config/pre_split.yml, not the callback that takes df.

What to edit

The filter contract

filter_step builds a RowFilterFunction step:

filter_step(name, function, *, columns, params=None, inference_context=None)
Callback you write Required return When Skyulf calls it
function(df); with nonempty params, function(df, params) A pandas Boolean Series with exactly one non-null True/False per row, aligned to df.index Generated pre-split eligibility before train/holdout splitting; optionally reused by the saved scoring policy before prediction

df is a private pandas copy, also for Polars models. columns declares the columns the filter reads; they are read automatically and need not be model inputs. Decide missing values explicitly, for example (df["x"] > 0).fillna(False). Use only data known at the observation cutoff; eligibility must not depend on future information.

Custom pre-split steps remain declared fixed filters: they preserve survivor values and order and cannot learn statistics. The generated config/scoring.yml defaults to mode: pre_split, which reuses the saved filters on unlabeled score input. Filters reading the target require an explicit skip choice, shown below.

The shipped completeness filter treats null/NaN as missing; blank strings and infinity count as values. With three selected fields and min_present=2, rows with two or three observed values survive without any value or order changes.

Example: an adult-only filter

For this example, start with a single regression model, engine: pandas, with numeric inputs age and income, target claim_amount, and record_id as the record key. Keep the key outside model inputs. The fixed business rule is age at least 18; missing ages are ineligible.

Create src/features/eligibility_rules.py. The callback receives rows; the factory receives only the YAML parameters and returns one step.

# src/features/eligibility_rules.py
from skyulf.preprocessing import filter_step


def adult_rows(df, params):
    """Keep rows meeting a fixed age limit; reject missing ages."""
    return (df[params["column"]] >= params["minimum"]).fillna(False)


def adults_only(column, minimum=18):
    """Build a fixed eligibility filter without learning from the data."""
    return filter_step(
        "adult_only",
        adult_rows,
        columns=[column],
        params={"column": column, "minimum": minimum},
    )


def target_present(df, params):
    """Keep training rows whose actual target is known."""
    return df[params["column"]].notna()


def known_target(column):
    """Declare target usage so scoring can require an explicit skip choice."""
    return filter_step(
        f"known_{column}",
        target_present,
        columns=[column],
        params={"column": column},
    )

Replace config/pre_split.yml with the following recipes, or merge the two named recipes into the existing recipes mapping. Keep one default recipe.

version: 1
recipes:
  default: []
  none: []
  adult_custom:
    - custom: eligibility_rules.adults_only
      params: {column: age, minimum: 18}
  adult_labeled:
    - custom: eligibility_rules.adults_only
      params: {column: age, minimum: 18}
    - custom: eligibility_rules.known_target
      params: {column: claim_amount}

Here eligibility_rules.adults_only resolves to src/features/eligibility_rules.py and calls adults_only(column="age", minimum=18). Activate adult_custom by merging these fields into the existing config/training.yml. Retain its generated table, model, split, quality and resource settings. The generated config/preprocessing.yml already has an empty none recipe.

defaults:
  input_columns: [age, income]
  target_column: claim_amount
  pre_split_recipe: adult_custom
  preprocessing_recipe: none

The command

Run from the generated project directory:

python src/tools/smoke.py
python src/tools/preview.py --action train
python src/tools/preview.py --action score

Smoke should return "status": "passed" with "project_hooks_executed": false: it checks syntax and declarations. Training preview resolves adult_custom to the adult_only step. Score preview validates the configured scoring workflow; previews make no remote reads and do not apply filters to rows. The new filter must appear in the training preview before training a new model.

What you should see

For example, these training inputs retain record IDs 102, 103, 104, 105, in that order, with all survivor values unchanged. These small samples illustrate the filter; use a source large enough for the configured train/holdout split and CV when training a Bundle model:

record_id,age,income,claim_amount
101,17.0,10.0,1.0
102,18.0,20.0,2.0
103,30.0,30.0,3.0
104,50.0,40.0,4.0
105,70.0,50.0,5.0
106,,60.0,6.0

After training, the default mode: pre_split uses the saved filter on unlabeled rows. For these model inputs (supply record keys separately when running the Bundle batch job):

age,income
17.0,10.0
18.0,20.0
30.0,30.0
,60.0

The four result rows have scoring_status [excluded, predicted, predicted, excluded]. Excluded rows remain in the result with an empty prediction and exclusion_reason: pre_split:adult_only; only the two survivors reach the model.

Filters that read the target

To also require a known training label, select pre_split_recipe: adult_labeled. For example, if record 104 above has a missing claim_amount, the two filters retain 102, 103, 105. Because the target is unavailable at scoring time, the default skip setting makes training preview stop with:

Pre-split step 'known_claim_amount' reads target 'claim_amount'. Set SKIP_TARGET_PRE_SPLIT_STEPS=True or SCORING_MODE='custom'.

If the intended scoring population is adults regardless of label availability, replace config/scoring.yml before training:

version: 1
mode: pre_split
skip_target_pre_split_steps: true
eligibility: []
outputs: []
combined: []

Rerun smoke and both previews. Training still applies both filters; scoring skips known_claim_amount and keeps adult_only, so the unlabeled scoring example has the same four outcomes. The choice is saved with each new model version; editing the file does not change an already trained model.

This switch skips an entire target-reading filter. Keep feature eligibility and target checks in separate steps if the feature rule must remain active. mode: custom instead uses only the separately configured scoring rules; merely switching modes does not activate any commented examples. See scoring eligibility.

Common errors and fixes

Symptom Fix
A custom factory cannot be resolved Use a path relative to src/features/, ending in the factory name; retain __init__.py in subpackages.
A target-reading filter prevents unlabeled scoring Decide whether it is training-only and set skip_target_pre_split_steps in scoring.yml explicitly before training. See troubleshooting.
Filter function must return a boolean Series with one value per row. Return a pandas Series aligned to df.index, one value per row.
Filter function must return only True/False values, e.g. df['age'] >= 18. Fill missing comparisons explicitly, for example with .fillna(False).
A filter needs learned statistics Move the rule to fitted preprocessing; pre-split filters stay fixed.
Edits do not alter an existing model Train a new version; saved source and filter declarations are immutable inputs to scoring.