Write custom preprocessing steps
When you need it
Use a custom factory when the built-in preprocessing catalog
does not express your calculation. This page covers fixed and learned column
transformations in config/preprocessing.yml. Fixed row filters that run before
the split have their own page: write your own pre-split filter.
Shipped factories live in src/features/preprocessing.py and are selected as
preprocessing.<factory>, for example preprocessing.frequency_encoding. Add your
own factory to that file, or keep a larger group in its own module under
src/features/ and select it as <file>.<factory>. For example, factory
clip_outliers in src/features/income_rules.py is income_rules.clip_outliers.
Paths are relative to src/features; preprocessing, pre_split, scoring and
groups are the generated modules.
flowchart LR
A["Raw training rows"] --> B["Fixed pre-split filters"]
B --> C["Training fold"]
C --> D["learn: saved state"]
C --> E["apply: transformed training rows"]
D --> E
D --> F["apply: validation or scoring rows"]
A column_step applies a fixed function without learning state. A
fitted_step learns separately inside each fold and reuses that state during
apply. A filter_step supplies the fixed mask before splitting; see
pre-split filters.
What to edit
Custom preprocessing recipes
Select per-step columns when mixing numeric and categorical features. Generated
projects contain custom Python examples in src/features/. The YAML files own
the ordered recipe lists; Python owns only the selected functions/factories:
| File | Owns |
|---|---|
config/pre_split.yml |
Fixed eligibility recipes |
config/preprocessing.yml |
Learned preprocessing recipes |
src/features/preprocessing.py |
Shipped transformation factories, selected as preprocessing.<factory> |
src/features/pre_split.py |
Shipped filter factories, selected as pre_split.<factory> |
src/features/<file>.py |
Your own module of factories, selected as <file>.<factory> |
For example, select the shipped frequency encoder in config/preprocessing.yml:
version: 1
recipes:
default:
- custom: preprocessing.frequency_encoding
params: {columns: [category]}
Select preprocessing_recipe: default for the model. To add a built-in node, add
its name, transformer and params entry to that same ordered list. Customize
the Python factory when the calculation itself must change. The generated
PREPROCESSING.md explains factory names, assets and advanced class examples.
Frequency encoding learns count / training_rows per observed string category.
For training values ["A", "A", "B", null], scoring ["A", "B", "NEW", null]
produces [0.5, 0.25, 0.0, 0.0]. It replaces selected columns in place, preserving
other columns and row order. Each CV fold learns its own mapping; saved-model
inference never recomputes frequencies on the score batch. Missing/unseen values
map to zero. Cast numeric category identifiers to strings upstream if needed.
Select these columns in workflow input_columns, excluding target/record keys.
python src/tools/preview.py --action train
See the local tools reference
or the generated src/tools/README.md for preview flags, defaults and scope.
Writing your own step
The function helpers build ordinary Core steps: column_step produces
ColumnFunction, fitted_step produces FittedFunction, and filter_step
produces RowFilterFunction. The YAML factory receives keyword parameters and
returns one step dictionary; Skyulf invokes the callbacks later with the rows.
For scikit-learn users: learn is fit, apply is transform, and the saved state dictionary contains the fitted attributes. Keep these public helper names; the descriptive callback names below explain the calculation without changing the API used by existing projects.
Function signatures and execution
These are the helper signatures. Optional inference_context declares row,
group, window or global dependence for diagnostics. Alone it does not grant
Spark or REST serving admission: training lints and probes your steps first, and
"row" is optional there while group, window and global stay rejected. See
Admit your own row steps
and, when a step is rejected,
When the probe fails.
column_step(name, function, *, output, replace=False, params=None, inference_context=None)
fitted_step(name, learn, apply, *, output, replace=False, params=None, inference_context=None)
filter_step(name, function, *, columns, params=None, inference_context=None)
| Helper | Callback signatures you write | Required return | When Skyulf calls it |
|---|---|---|---|
column_step |
function(df); with nonempty params, function(df, params) |
Series, DataFrame, NumPy array or list, with one value per input row per output column | Transform training rows in each CV fold/final fit, validation rows and scoring rows; no statistics are learned |
fitted_step learning |
learn(df, y); with nonempty params, learn(df, y, params) |
A JSON-compatible dict with string keys, no NaN or infinity | Each CV training fold and final training fit; never refit on validation or scoring rows |
fitted_step applying |
apply(df, state); with nonempty params, apply(df, state, params) |
The same column-return contract as column_step |
Transform training/validation/scoring rows with that fit's saved state |
filter_step |
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 DataFrame copy even for a Polars pipeline. Column/fitted
callbacks do not see the target column in df; y is a private training target
passed to learn (or None if no target is supplied). state is exactly the
learned dictionary restored from the artifact. params is the helper's saved
user-configuration dictionary, passed as the last positional argument only
when nonempty. With params=None or {}, omit that argument in your callback
signature or give it a default. YAML params first configures the factory; the
factory explicitly passes its callback parameters into the helper.
For column outputs, preserve row count/order and any pandas index; return as
many columns as output declares. A Series names one output; a DataFrame/array
supplies outputs in the declared order. output supplies the final column names.
Use replace=True only when replacing existing columns; a callback must not
replace the target. Filter callbacks instead may read declared target columns
when explicitly configured, but remain fixed row-local rules with no learning:
see target-reading filters
for the saved scoring skip choice. Filters preserve every retained value and row order.
One output: the shipped log feature
This is the generated src/features/preprocessing.py example. It adds a column
and leaves income unchanged. Its callback has nothing to learn.
import numpy as np
from skyulf.preprocessing import column_step
def log1p_value(df, params):
"""Return log(1 + value); negative values count as 0."""
return np.log1p(df[params["column"]].clip(lower=0))
def log_feature(column):
"""Add log_<column> next to the original column."""
return column_step(
f"log_{column}", log1p_value, output=f"log_{column}", params={"column": column}
)
version: 1
recipes:
default: []
with_log:
- custom: preprocessing.log_feature
params: {column: income}
Select preprocessing_recipe: with_log and include income in input_columns.
For income [-2, 0, 3, null], log_income is [0, 0, 1.3862943611, NaN]:
negative values are clipped to zero and missing values stay missing. A model
that cannot accept missing values needs a later imputation step in its recipe.
Two outputs: a custom budget calculation
Create src/features/budget.py. This example
adds a net amount and an expense ratio. Zero or missing income makes the ratio
missing; this example does not invent a denominator or fill that value.
import pandas as pd
from skyulf.preprocessing import column_step
def budget_values(df, params):
"""Return net income and expense share in the original row order."""
income = df[params["income"]]
expenses = df[params["expenses"]]
denominator = income.where(income.ne(0))
return pd.DataFrame(
{"net_income": income - expenses, "expense_share": expenses / denominator}
)
def budget_columns(income="income", expenses="expenses"):
"""Add two fixed calculations without learning population statistics."""
return column_step(
"budget_columns",
budget_values,
output=["net_income", "expense_share"],
params={"income": income, "expenses": expenses},
)
version: 1
recipes:
default: []
with_budget:
- custom: budget.budget_columns
params: {income: income, expenses: expenses}
Select preprocessing_recipe: with_budget and input columns income, expenses.
Rows (income, expenses) of (100, 25), (80, 40), (0, 10) and (null, 5)
produce (net_income, expense_share) of (75, 0.25), (40, 0.5), (-10, NaN)
and (NaN, NaN). The two original columns and row order remain unchanged. Add
appropriate missing-value handling before an estimator that requires finite inputs.
Learned mapping: the shipped frequency encoder
These are the actual generated preprocessing.py callbacks and factory. The
factory connects learning and applying; its columns list is saved as callback
params. You normally select the existing factory instead of copying it elsewhere.
import pandas as pd
from skyulf.preprocessing import fitted_step
def learn_frequencies(df, y, params):
"""Learn: share of training rows per category (null is not a category)."""
if len(df) == 0:
raise ValueError("Frequency encoding requires nonempty training rows.")
return {column: (df[column].value_counts() / len(df)).to_dict() for column in params["columns"]}
def apply_frequencies(df, state, params):
"""Apply: look up the saved share; unseen or null categories get 0."""
return pd.DataFrame(
{
column: df[column].map(state[column]).astype(float).fillna(0.0)
for column in params["columns"]
}
)
def frequency_encoding(columns):
"""Replace string category columns with their training frequency."""
if not isinstance(columns, (list, tuple)) or not columns:
raise ValueError("Frequency columns must be a nonempty list of column names.")
if any(not isinstance(column, str) or not column for column in columns):
raise ValueError("Frequency columns must be nonempty column names.")
if len(set(columns)) != len(columns):
raise ValueError("Frequency columns must be unique.")
columns = list(columns)
return fitted_step(
"frequency_encoding",
learn_frequencies,
apply_frequencies,
output=columns,
replace=True,
params={"columns": columns},
)
version: 1
recipes:
default: []
with_frequency:
- custom: preprocessing.frequency_encoding
params: {columns: [category]}
Select preprocessing_recipe: with_frequency with category as a raw input.
Training on ['A', 'A', 'B', null] learns
{'category': {'A': 0.5, 'B': 0.25}}. The denominator includes all four training
rows, while null is not a category. Applying to ['A', 'B', 'NEW', null] returns
[0.5, 0.25, 0.0, 0.0]. Repeated or differently sized score batches reuse those
same fractions. Saved-model loading restores the mapping and Python source;
it does not count the incoming categories again. Use string category values so
the learned dictionary has valid string keys.
Learned category groups: learn and apply without numeric encoding
The generated rare_categories example separates the same two phases but keeps
text. It is useful before a numeric encoder when rare and unseen categories
should share a deliberate fallback value.
from skyulf.preprocessing import fitted_step
def learn_common_categories(df, y, params):
"""Learn: categories that cover at least min_share of the training rows."""
shares = df[params["column"]].value_counts() / len(df)
common = shares[shares >= params["min_share"]].index
return {"keep": sorted(str(category) for category in common)}
def apply_common_categories(df, state, params):
"""Apply: keep learned categories and nulls; everything else becomes `other`."""
values = df[params["column"]].astype(object) # object: "Other" is allowed as a value
keep = values.isna() | values.astype(str).isin(state["keep"])
return values.where(keep, params["other"])
def rare_categories(column, min_share=0.05, other="Other"):
"""Replace categories seen in less than min_share of training rows with `other`."""
if not 0 < min_share < 1:
raise ValueError("min_share must be between 0 and 1, e.g. 0.05 for 5%.")
params = {"column": column, "min_share": min_share, "other": other}
return fitted_step(
f"rare_{column}",
learn_common_categories,
apply_common_categories,
output=column,
replace=True,
params=params,
)
version: 1
recipes:
default: []
grouped_categories:
- custom: preprocessing.rare_categories
params: {column: city, min_share: 0.3, other: Other}
- custom: preprocessing.frequency_encoding
params: {columns: [city]}
Select preprocessing_recipe: grouped_categories and include city in the
raw inputs. With training cities ['London', 'London', 'London', 'Paris', 'Oslo'],
learn_common_categories saves {'keep': ['London']} at min_share: 0.3.
Applying the rare-category step to ['London', 'Paris', 'Tokyo', null] gives
['London', 'Other', 'Other', null]; null stays null and the unseen city uses
Other. In the complete recipe, the following frequency step learns from the
transformed training rows, so the final four values are [0.6, 0.4, 0.4, 0.0].
This demonstrates ordered steps; neither mapping is relearned during scoring.
For fixed row eligibility, the
pre-split filter page
shows the third helper. The shipped alternatives are pre_split.value_range,
pre_split.allowed_values and pre_split.minimum_completeness: select the factory
with custom: in config/pre_split.yml, not the callback that takes df.
Learning from the target: a small category-mean example
Create src/features/target_mean.py. This educational example uses the
training y to learn a mean per string category. Its apply function receives no
target; missing or unseen categories use the saved global training mean.
import pandas as pd
from skyulf.preprocessing import fitted_step
def learn_target_means(df, y, params):
"""Learn category means and a fallback from this training fold's targets."""
if y is None:
raise ValueError("Target-mean encoding requires training targets.")
labels = pd.Series(y, index=df.index, dtype=float)
means = labels.groupby(df[params["column"]]).mean().to_dict()
return {"means": means, "fallback": float(labels.mean())}
def apply_target_means(df, state, params):
"""Use saved means without reading or requiring scoring targets."""
return df[params["column"]].map(state["means"]).fillna(state["fallback"])
def target_mean(column):
"""Replace a string category with its learned target mean."""
return fitted_step(
f"target_mean_{column}",
learn_target_means,
apply_target_means,
output=column,
replace=True,
params={"column": column},
)
version: 1
recipes:
default: []
with_target_mean:
- custom: target_mean.target_mean
params: {column: category}
Select preprocessing_recipe: with_target_mean with input category and your
normal target column. For categories ['A', 'A', 'B', 'B'] with targets
[0, 1, 2, 3], the state is
{'means': {'A': 0.5, 'B': 2.5}, 'fallback': 1.5}. Applying to
['A', 'B', 'NEW', null] without y returns [0.5, 2.5, 1.5, 1.5].
Cross-validation learns from each fold's training rows, so that mapping does not read validation targets. This does not make a naive target encoder universally leakage-safe: transforming its own training rows with their fitted category mean can overfit, especially for singleton categories. This tiny helper has no internal out-of-fold encoding, smoothing or time-aware exclusion. Use an appropriately validated target-encoding recipe and splitting policy for real target-dependent feature engineering. Never build this mapping on the entire dataset before splitting.
Clipping with learned bounds
The next example creates a new fitted factory rather than selecting a shipped one.
# src/features/income_rules.py
from skyulf.preprocessing import fitted_step
def learn_bounds(df, y, params):
"""Learn clip limits from the training fold only."""
values = df[params["column"]]
return {"low": float(values.quantile(0.01)), "high": float(values.quantile(0.99))}
def clip_values(df, state, params):
"""Clip every later batch with the saved training limits."""
return df[params["column"]].clip(state["low"], state["high"])
def clip_outliers(column):
"""Replace column with its value clipped to training 1%-99% quantiles."""
params = {"column": column}
return fitted_step(f"clip_{column}", learn_bounds, clip_values,
output=column, replace=True, params=params)
Select that factory in config/preprocessing.yml:
version: 1
recipes:
default: []
custom_clip:
- custom: income_rules.clip_outliers
params: {column: income}
The dotted path starts at src/features/: income_rules means
src/features/income_rules.py, and clip_outliers is the top-level factory in
that file. The loader calls clip_outliers(column="income"); it does not pass rows
to the factory. A module in a subfolder also needs that folder's __init__.py.
Set preprocessing_recipe: custom_clip under defaults or the selected model in
config/training.yml, and include income in input_columns. The empty default
recipe does not activate custom_clip; neither does merely creating the Python
file. After saving both files, run these commands from the generated project:
python src/tools/smoke.py
python src/tools/preview.py --action train
python src/tools/preview.py --action score
Smoke checks syntax and declarations without executing the factory. Training
preview resolves the selected clip_income step; score preview checks the
configured scoring workflow. Neither command fits quantiles or predicts rows.
For training incomes [10, 20, 30, 40], the fitted bounds are 10.3 and 39.7;
later incomes [-1000, 1000] clip to those saved bounds.
learn runs on the training rows of every CV fold and of the final fit; its
dict is saved with the model and apply reuses it for validation and scoring
rows; validation/scoring rows do not fit those quantiles. y is the training
target; the target column is
removed from df in both functions, so apply can never depend on it. Rules:
- Use top-level
deffunctions, not lambdas or nested functions. dfis a pandas copy, also for Polars models; changing it has no effect.- Return one value per row in the same order. Filters return True/False per
row; decide missing values explicitly, e.g.
(df["x"] > 0).fillna(False). - Learned dicts must be JSON-like: string keys and no NaN. NumPy numbers are converted automatically.
- Nonempty
params={...}is passed to every function as its last argument; empty params are omitted from the callback call. - Use
replace=Trueto overwrite existing columns; otherwise outputs must be new.
Errors name the failing function, e.g. Project function ...:learn_bounds failed:
KeyError: 'income'.
Most former Calculator/Applier pairs fit in these helpers. The advanced
custom_step factory below instead
connects Calculator.fit(X, y, config) to Applier.apply(X, y, state). Choose
that class pair only when the learned state is not a small dict
(for example a fitted scikit-learn object), the step changes the number of rows
during preprocessing, or it needs native Polars/Spark code for performance.
These helper steps are project code only: the web canvas and backend API reject
them, because a graph must never name an arbitrary server function.
Advanced: a Calculator/Applier class pair
This is the shipped preprocessing.rare_categories step written as a class pair.
The class version receives pandas or Polars (X.to_native()), rebuilds the whole
table itself and can keep native Polars code. Prefer fitted_step unless you need
that, a learned state that is not a small dict, or a step that removes columns or rows.
# src/features/rare_class_step.py
import polars as pl
from skyulf.inference.project_code import custom_step
from skyulf.preprocessing.base import BaseApplier, BaseCalculator, apply_method, fit_method
class RareCategoryCalculator(BaseCalculator):
"""fit(): runs on the training rows of every CV fold and returns what it learned."""
@fit_method
def fit(self, X, y, config):
"""Learn the categories covering at least min_share of the training rows."""
native = X.to_native() if hasattr(X, "to_native") else X
values = native[config["column"]]
values = values.to_pandas() if isinstance(values, pl.Series) else values
shares = values.value_counts() / len(values)
common = shares[shares >= config["min_share"]].index
return {
"column": config["column"],
"other": config["other"],
"keep": sorted(str(category) for category in common),
}
class RareCategoryApplier(BaseApplier):
"""apply(): runs on train, test and scoring rows using only the saved fit() result."""
@apply_method
def apply(self, X, y, state):
"""Replace categories not in the saved list with `other`; nulls stay null."""
native = X.to_native() if hasattr(X, "to_native") else X
if isinstance(native, pl.DataFrame):
return _apply_polars(native, state)
return _apply_pandas(native, state)
def _apply_polars(frame, state):
"""Native Polars expression: no conversion to pandas."""
column = pl.col(state["column"])
keep = column.is_null() | column.cast(pl.Utf8).is_in(state["keep"])
replaced = pl.when(keep).then(column.cast(pl.Utf8)).otherwise(pl.lit(state["other"]))
return frame.with_columns(replaced.alias(state["column"]))
def _apply_pandas(frame, state):
"""Pandas version; returns a copy so the caller's table is never changed."""
values = frame[state["column"]].astype(object)
keep = values.isna() | values.astype(str).isin(state["keep"])
result = frame.copy()
result[state["column"]] = values.where(keep, state["other"])
return result
def class_rare_categories(column, min_share=0.05, other="Other"):
"""Build the step: replace rare categories in column with `other`."""
if not 0 < min_share < 1:
raise ValueError("min_share must be between 0 and 1, e.g. 0.05 for 5%.")
return custom_step(
f"class_rare_{column}",
RareCategoryCalculator,
RareCategoryApplier,
params={"column": column, "min_share": min_share, "other": other},
)
Select it in config/preprocessing.yml:
version: 1
recipes:
default: []
rare_city_class:
- custom: rare_class_step.class_rare_categories
params: {column: city, min_share: 0.05}
Training saves the exact Python source with the fitted artifact. Both local
and MLflow loading restore this saved source, including custom classes. Changing
the project file affects future training; score, approve and rollback continue
using saved model code/state. Different source versions use distinct package
identities. All Python files under src/features/ are captured in a bounded
64 KiB snapshot, excluding root groups/ producers. Use relative imports and an
__init__.py in every subpackage. Declared assets can be captured as described
below; third-party dependencies must be installed explicitly in both training
and scoring environments. Keep job entrypoints outside the feature package.
Only load trusted code/models, as with existing pickle artifacts. Historical
feature context and post-prediction business rules have their own configuration.
Pre-split filters
Fixed row filters built with filter_step have their own page:
write your own pre-split filter.
Does custom: work for every custom function?
It is a general selector, not a special case for log_feature. The example calls
src/features/preprocessing.py -> log_feature(column="income") to build one
step. That factory returns column_step(...); Skyulf later calls its calculation
function with the actual rows. YAML does not pass a DataFrame into the factory.
| Custom work | Factory returns | Selected from |
|---|---|---|
| Fixed formula producing one or more columns | column_step(...) |
preprocessing.yml |
| Transformation learning state from training rows | fitted_step(...) |
preprocessing.yml |
| Fixed row eligibility mask | filter_step(...) |
pre_split.yml |
| Advanced Calculator/Applier pair | custom_step(...) |
Its supported phase; see a class pair |
| Spark aggregation producing a feature table | Spark DataFrame | features.yml -> groups, using file.py:function |
The factory must be a top-level function defined in the referenced captured
project module, accept the YAML params as keyword arguments, and return one
valid Core step dictionary. Select multiple factories as separate YAML entries.
A raw def transform(df) returning a DataFrame, a sklearn transformer instance,
a list of steps or a lambda is not a valid custom: factory. Wrap the calculation
using the matching helper; do not rewrite its business logic inside the loader.
Calculation functions used by the helpers must also be saved top-level functions.
column_step/fitted_step preserve rows and return one value per row per declared
output; their callbacks receive pandas frames. A fitted step separates learning
from applying a saved, JSON-compatible state dictionary. filter_step returns
one non-null Boolean per input row, uses fixed row-local rules and changes no
values. Pre-split is not a place to fit a scaler or learn population statistics.
Declare external dependencies in the project's deployment requirements and
package declared assets; YAML does not install packages or capture live services.
These custom examples work in the local pipeline. Spark batch and REST serving require a supported fitted pipeline; an arbitrary custom function is not automatically admitted for those runtimes. Check the scope in feature tables and serving.
Keep named recipes under recipes in each phase YAML file and select one
per model using preprocessing_recipe. Competition can select different
preprocessing recipes per candidate while sharing pre-split eligibility.
Independent-target projects can select preprocessing and pre-split recipes per
model. Both YAML files require version: 1 and a default recipe; none means
no steps. Recipes prefixed example_ remain inactive until selected. Do not add
Python build_preprocessing or build_pre_split_steps builders for a phase owned
by YAML. Existing Python-only projects continue to work without these YAML files.
Check the resolved project before training:
python src/tools/smoke.py
python src/tools/preview.py --action train
python src/tools/preview.py --list-preprocessors
Smoke validates the declarations without calling custom factories; preview resolves the selected recipes and executes trusted factories. Keep I/O out of module imports and factories.
The saved model contains resolved steps, fitted state, captured YAML declarations and project Python. Prediction does not re-read current recipe files or fit new means. Edits apply to newly trained model versions. Upstream Spark feature jobs are separate: a model trained on a merged table still needs those input features; it does not automatically execute the upstream joins from a raw table.
The command
From the generated project directory, with its pinned dependencies installed:
python src/tools/smoke.py
python src/tools/preview.py --action train
python src/tools/preview.py --action score
Smoke parses the package; preview resolves trusted factories. A real training run is needed to learn and save state. Follow training and reports for that step.
Try the callbacks locally before training
Smoke and preview check declarations and resolve factories; they do not run
learn or apply on rows. After creating the clipping file above, run this
literal example from the generated project root with its pinned dependencies.
It uses the shipped frequency encoder and your income_rules.py
factory. fit_transform returns the transformed training frame and metrics;
transform returns only the transformed scoring frame.
import sys
from pathlib import Path
import pandas as pd
from skyulf.preprocessing import FeatureEngineer
sys.path.insert(0, str(Path("src/features").resolve()))
from preprocessing import frequency_encoding
from income_rules import clip_outliers
engineer = FeatureEngineer([frequency_encoding(["city"]), clip_outliers("income")])
training = pd.DataFrame(
{"city": ["A", "A", "B", None], "income": [10.0, 20.0, 30.0, 40.0]}
)
transformed_train, _ = engineer.fit_transform(training)
scoring = pd.DataFrame(
{"city": ["A", "NEW", None, "B"], "income": [-1000.0, 1000.0, 25.0, None]}
)
transformed_score = engineer.transform(scoring)
print(transformed_score)
Expected city values are [0.5, 0.0, 0.0, 0.25]; expected income values are
[10.3, 39.7, 25.0, NaN]. These are the training frequencies and saved quantile
bounds, not statistics recomputed from the scoring frame. This local exercise
runs real callbacks, but does not register a model, contact Databricks or certify
Spark/REST execution. The generated training workflow captures the project
source when it later trains and saves the actual model.
For a fixed lookup file or third-party package, follow
captured source, assets and dependencies.
Use inference_context="row", "group" or "window" only when it accurately
describes your callback's input dependence; the declaration is metadata, not a
worker or serving certificate. See inference context
for the separate admission boundary.
What you should see
The examples above show expected outputs, saved learned state and clipping bounds. After reload, predictions use the captured recipe and source, even if the editable project has changed.
Captured source, assets and dependencies
Training freezes src/features/, excluding the root groups/ directory, as a versioned 64 KiB
package snapshot. Spark producers in groups/ execute separately in the feature job. Keep custom
model functions in their phase modules; do not import producers from model recipes. See
feature tables for the stage boundary. Declare bounded data files in
src/features/assets.json as files: [...] (the file itself uses JSON), and read them with
read_project_asset from skyulf.inference.project_package. Paths stay inside the package;
symlinks are rejected. Code, captured recipe declarations, assets and exact dependency pins share
the snapshot budget. Use distribution==version in src/features/requirements.txt and install
those versions in training/scoring environments; loading verifies them without installing.
YAML and Python edits apply to the next training load. Saved models retain resolved settings, recipe declarations, fitted preprocessing and frozen source; scoring and lifecycle replay do not reread editable YAML or Python. Changing rules/assets/pins requires a new model.
python src/tools/smoke.py
python src/tools/preview.py --action train
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 | See pre-split filters. |
| A helper changes row count or ordering | Return one value per input row, or a fixed filter mask. Use supported advanced contracts for other transformations. |
| Edits do not alter an existing model | Train a new version; saved source and fitted state are immutable inputs to scoring. |
| Reload rejects dependencies or the source package | Keep exact pins, package-local asset paths and the snapshot budget; installing packages is an explicit environment step. |
See context and diagnostics for group/window operations and feature tables for Spark producers that run before the model pipeline.