Skip to content

Preprocessing diagnostics and context

Use preprocessing diagnostics to check whether a saved transformation behaves consistently when the same sample is repeated, reordered, or split into batches. Install skyulf-core with the dependencies used to fit the model. The complete runnable example trains a tiny model only to make a saved artifact available; diagnosing your own artifact does not retrain it. The existing step still owns its calculation. Training calls fit; inference calls apply with the information saved during training.

What does context mean?

Context describes the other input rows needed during inference. It does not describe how much data training needed, choose an engine, or change the formula.

Context Information required at inference Example Current probe behavior
row This row and saved training state Saved mean, scaler, category mapping Compare full, repeated, chunked and reversed requests
group All relevant rows in the same group Subtract this request's customer-group mean Report requires_context; do not run independent chunks
window Neighboring or earlier rows, usually in a defined order Lag or rolling mean Report requires_context; do not invent history
global The complete intended input population Choose duplicates across the whole request Report requires_context if the step is active
unknown Not yet declared for this implementation/configuration An undeclared custom callback Try sample comparisons; retain unknown even when they pass

These declarations currently guide an explicit diagnostic. They do not create a Spark grouping/shuffle, sort the data, fetch older records or configure serving. Training can opt into the diagnostic below. Prediction does not run it.

Why is a fitted GroupImputer a row operation?

Suppose training learned these income replacement values:

Segment Saved mean income
A 200
B 1200

For a new row {segment: A, income: null}, GroupImputer.apply looks up the saved A value, 200. That remains 200 whether the request contains one row or a thousand rows. The training-time aggregation is already finished. Its inference context is therefore row, even though the step's name contains "Group". Unseen groups use the fitted fallback according to the node's saved policy.

Compare that with a custom function computing df.groupby("customer_id")["amount"].transform("mean") on the incoming request. For one customer with amounts 10 and 30, the complete group gives 20 for both rows. Separating those rows into singleton requests gives 10 and 30. This is group context: the request must contain the intended complete groups. Saving the function in a model does not save future customer transactions.

Window and global examples

A rolling mean over 10, 20, 30 with a two-row window is 10, 15, 25 when the first window accepts one row. Independently processing chunks [10, 20] and [30] loses the previous value for the last chunk. Declaring window makes this requirement visible. Carry-history mode additionally needs an explicit continuation session; restarting each request from training history is not equivalent to a continuous stream. The current probe does not run that session.

Predictions with continued history

Use the existing carry-history modes of LagFeatures and RollingAggregate through score_pipeline_with_history. Supply the actual request frame in the saved engine/schema. Each step retains its own transformed tail; subsequent requests receive the returned JSON-compatible state:

from skyulf.inference.pipeline_scoring import score_pipeline_with_history

first = score_pipeline_with_history(first_batch, artifact)
second = score_pipeline_with_history(
    next_batch, artifact, history_state=first.history
)
predictions = second.frame
next_history = second.history

The first call starts from the saved training seed. For a complete initial history replay, use bootstrap_history=True on that first call to exclude the seed; do not combine it with history_state. Calls neither fetch older rows nor change the artifact. Saved state belongs to its model digest and engine. Existing ordering, tie-breaker, per-group limits and late/repeated-row checks still apply. Empty requests preserve validated history and return nullable prediction columns. Models without carry steps return history=None and reject supplied history.

Persist predictions and returned history atomically. Serialize calls sharing a history stream or use compare-and-swap in your storage so two writers cannot overwrite each other's progress. Reusing an old detached state can replay an old request; this API cannot detect storage races. Databricks incremental scoring already owns its durable history/receipt path and uses the same session factory.

For custom group, window or global callbacks, pass the complete intended request, including required context rows, through whole-frame scoring. The wrapper preserves that request as one frame; it cannot infer group completeness, build custom history, or admit independent Spark partitions. Built-in carry history does not provide state for arbitrary custom callbacks.

Deduplication needs all rows in the intended deduplication population; duplicates in different partitions cannot be discovered independently. Its declared context is global. However, ordinary row-preserving prediction skips the built-in Deduplicate step. The probe follows that same rule and reports action: skip_preserve_rows, status: skipped; it does not remove predictions.

Where do I declare context?

Built-in declarations belong to the existing Skyulf preprocessing implementation. You do not add a context setting to every built-in YAML entry. Some built-in configurations/engines are not reviewed yet and will report unknown.

For a custom function, declare its actual behavior in the existing factory in src/features/preprocessing.py. For example, replace the shipped log_feature factory with this version; keep its existing log1p_value calculation:

def log_feature(column):
    """Add a per-row logarithm with no request-time population statistics."""
    return column_step(
        f"log_{column}",
        log1p_value,
        output=f"log_{column}",
        params={"column": column},
        inference_context="row",
    )

In a generated Bundle, select it normally in config/preprocessing.yml:

version: 1
recipes:
  default:
    - custom: preprocessing.log_feature
      params: {column: income}

The YAML selects the factory; the returned step contains the context. It is not a second YAML definition of the calculation. For fitted_step, use the same keyword when apply(df, state) only reads saved state. Do not label a callback row if it recalculates statistics from df during prediction.

filter_step also accepts this diagnostic keyword. Its pre-split contract still requires fixed, row-local eligibility rules; group/window/global declarations do not make context-dependent pre-split filters valid. The preprocessing probe does not inspect the separate project scoring/pre-split eligibility policy.

For an advanced custom class, add an optional static method to its existing Applier class, alongside its existing apply:

from skyulf.core.capabilities import ExecutionCapability

# Inside your existing Applier class:
@staticmethod
def inference_capability(state, *, engine):
    """Describe only engines and modes supported by this saved implementation."""
    if engine not in ("pandas", "polars"):
        return None
    return ExecutionCapability(engine, "apply", "local", "preserve", "row")

Use that example only if the class actually supports both engines and preserves rows. Otherwise narrow it. Omitting a declaration keeps context unknown. Editing the factory/class affects newly trained artifacts; an existing model continues using its captured source. A declaration is a claim to test, not a Spark/REST admission certificate.

What does report = probe_fitted_preprocessing(artifact, sample) do?

It means: take this already fitted pipeline, try its saved preprocessing on these example inputs in several ways, and return what happened.

Name What you provide or receive
artifact A FittedPipelineArtifact: the loaded pipeline plus its manifest and saved fitted state. It is not a model-name string, endpoint or unfitted estimator.
sample A small pandas or Polars DataFrame matching the saved model input columns, order and dtypes. It normally excludes the target and unrelated record keys.
report A Python dictionary containing step names, context, hashes, checks, statuses and failure reasons. It is not predictions or a trained model.

The artifact is loaded with load_pipeline(path) from an existing fitted pipeline artifact directory, such as one created by save_pipeline or fit_workflow. The loader verifies saved payload/package checks. Do not pass models:/..., an endpoint name, or the result of a generic pyfunc loader directly to this API; those are different objects.

If you already have the artifact, use a matching sample directly. If the model was trained on merged features, sample those features. The probe does not turn a raw source table into merged features or rerun upstream joins.

Complete runnable example

This standalone example creates a tiny fitted artifact so the origin of every variable is clear. In a real project, replace the training section with loading your existing artifact; you do not retrain to run the probe.

import json
from pathlib import Path

import pandas as pd

from skyulf.data.dataset import SplitDataset
from skyulf.inference.fitted_pipeline import load_pipeline, save_pipeline
from skyulf.inference.preprocessing_probe import probe_fitted_preprocessing
from skyulf.pipeline import SkyulfPipeline

training = pd.DataFrame({
    "segment": ["A", "A", "B", "B", "A", "B"],
    "income": [100.0, 300.0, 1000.0, 1400.0, 200.0, 1200.0],
    "target": [1.0, 3.0, 10.0, 14.0, 2.0, 12.0],
})
pipeline = SkyulfPipeline({
    "preprocessing": [
        {"name": "fill_income", "transformer": "GroupImputer",
         "params": {"columns": ["income"], "group_by": "segment", "strategy": "mean"}},
        {"name": "encode_segment", "transformer": "OneHotEncoder",
         "params": {"columns": ["segment"], "handle_unknown": "ignore", "max_categories": None}},
    ],
    "modeling": {"type": "linear_regression"},
})
pipeline.fit(
    SplitDataset(train=training, test=training.iloc[:0]),
    target_column="target",
)

# Training is finished. Save once; all following checks use this fitted state.
artifact_path = Path("artifacts/context_demo")
save_pipeline(pipeline, artifact_path)
artifact = load_pipeline(artifact_path)

# These are model inputs, including missing income and a previously unseen segment.
sample = pd.DataFrame({
    "segment": ["A", "B", "NEW"],
    "income": [None, 1600.0, None],
})
report = probe_fitted_preprocessing(artifact, sample, chunk_sizes=(1, 2))

print(report["status"])
for step in report["steps"]:
    print(step["name"], step.get("context"), step["status"])

# Optional: persist diagnostic evidence locally. No prediction table is written.
Path("preprocessing_report.json").write_text(
    json.dumps(report, indent=2), encoding="utf-8"
)

Expected summary:

passed
fill_income row passed
encode_segment row passed

The sample contains three rows. chunks:1 makes three separate one-row apply calls, and chunks:2 makes one two-row call and one one-row call. Each result is compared with the corresponding rows from the full-sample result. The learned segment means and categories remain the ones from training.

How to read the report

Optional training report

In generated config/training.yml, enable the diagnostic explicitly:

defaults:
  preprocessing_probe: true

The default is false. Single-model, competition and multi-target training use the same saved-model check. Each fit checks its saved and reloaded artifact using at most the first 256 holdout input rows and uses the probe's 8 MiB frame limit; it does not log the sample values or refit. The run records preprocessing_probe.json, displayed under Preprocessing diagnostics in the training report. The SDK equivalent is TrainingSpec(..., preprocessing_probe=True), passed to train_candidate. To enable it on an existing specification:

from dataclasses import replace

spec = replace(spec, preprocessing_probe=True)

Open the MLflow training run's Artifacts tab and select preprocessing_probe.json. In competition and multi-target layouts, inspect the individual candidate/branch run that fitted the pipeline.

failed, requires_context and empty-holdout not_run remain diagnostic outcomes. They do not block promotion or change the data identity, split, model, thresholds or aliases. An MLflow write error follows the existing training failure path. Custom callbacks remain trusted code with possible external side effects; the row/byte limits are not a sandbox or execution timeout.

Status fields

Field or status Meaning and next action
Top-level status: passed All required sample checks and the final feature schema matched. Inspect context and empty-input support too.
checks full, repeat, chunks:N, reverse and empty name the attempted strategy.
failed + output_mismatch Inspect the named step for batch statistics, order, dtype changes or numerical rounding.
state_mutation / input_mutation The step changed its saved state or received input during apply. Keep learned state read-only and return transformed data separately.
apply_error + error_type The existing apply raised. Reproduce the named step locally with trusted data; sample-bearing exception messages are omitted from the report.
invalid_step_contract Saved identity/configuration/validation could not be inspected. Check the artifact and the optional validator; do not infer numerical parity.
requires_context Supply a separately designed group/history execution path; changing the label to row does not resolve the dependency.
skipped The normal prediction chain skips this fitted training step. No apply test ran for it.
not_run An earlier step failed or required context, so no result is claimed for this step.
empty check: not_supported Empty input raised. Other checks can pass; this does not promise support for empty requests.
state_validation: node_owned The preprocessing owner inspected its saved state. This can be a pandas/Polars diagnostic contract; it does not mean worker admission.
state_validation: unavailable No applicable node-owned validator exists for this fitted state. Empirical checks may still pass.
admission: diagnostic_only The report grants no distributed or endpoint eligibility.

Validation of the initial artifact, sample schema and limits can raise an exception before a report exists. For example, wrong columns or more than max_rows are caller errors. Step execution failures normally appear in the report. The default limits are 256 rows and 8 MiB per frame; the probe rejects oversized samples instead of silently truncating them. These limits do not cap the project's full Spark scoring population.

Choose a nonempty sample with realistic values, nulls, unseen categories and varied group keys. Only bounded pandas/Polars frames with immutable scalar cells are supported; nested mutable cells are rejected. Comparisons are exact. A floating-point difference near machine precision may need investigation but does not by itself demonstrate refitting. No tolerance setting is currently exposed. Passing finite samples cannot establish behavior for every future row.

A row declaration describes the data the operation needs. It does not promise that every engine/configuration passes exact chunk comparisons. For example, pandas can choose different output dtypes for integer casts, clipped integer values or numeric replacements across chunks. The report must retain that failure even though neither operation needs other rows to calculate its values.

Practical built-in boundaries

Step Meaning for saved inference
Casting Saved categorical vocabularies can be reused per row. Legacy pandas category inference and coercive fallback conversions can depend on the complete request; the diagnostic reports that context.
GeneralBinning, KBinsDiscretizer, Winsorize Apply reuses training bins/limits. Integer pandas clipping may still fail exact chunk dtype comparisons; use representative model-input dtypes.
ManualBounds This is a row-local filter, not an automatically skipped step. In-bound prediction succeeds; a request that would lose rows is rejected to preserve prediction alignment.
DropMissingRows Native apply filters rows, but ordinary prediction skips it. Use an imputer for missing scoring values; skipping does not guarantee the model accepts nulls.
Oversampling, Undersampling These balance training classes using global context and are skipped during prediction. Mixed effects, replacement sampling and arbitrary callbacks can remain unknown.
DatasetProfile, DataSnapshot Fit saves training reports; apply passes prediction data through unchanged. The report or snapshot is not recomputed per request.
TrainTestSplitter, Split, feature_target_split These are training structure helpers, absent from saved fitted_steps and from the probe report. No prediction-time splitting occurs. The train/test splitter deliberately has no feature-frame context declaration.
DateFeatures New UTC-aware fitted settings can be inspected per row. Legacy non-UTC settings can stay unknown; existing generated-name collisions are not renamed by the probe.
PolynomialFeatures Apply reconstructs combinatorial terms from saved configuration and feature shape. Its internal sklearn fit does not learn means or categories. Empty/null inputs retain their existing errors.
TextCleaning, InvalidValueReplacement Fixed operations reuse saved settings. Null-only chunks, regex support and dtype changes are engine-specific, so include those cases in your sample.
DummyEncoder, LabelEncoder, OrdinalEncoder, WOEEncoder, HashEncoder Current category keys reuse deterministic saved settings. Legacy pandas datetime rendering may depend on the batch; refit the whole pipeline to update that contract. HashEncoder empty output can still fail strict schema comparisons.
TargetEncoder, WOEEncoder Prediction reuses the saved full-data mapping. TargetEncoder's out-of-fold training values deliberately differ from its inference values; WOE requires a binary training target.
KNNImputer, IterativeImputer Neighbors and prediction estimators come from training. IterativeImputer with active rounds needs request context because a completely null request takes a different initial-fill path. Custom/stochastic modes can stay unknown.
PowerTransformer, power rules in GeneralTransformation Lambdas and scaler values are saved. Existing error handling returns a complete column/frame unchanged after one invalid value, so active power transforms report global.
FeatureGeneration, FeatureMath, FeatureGenerationNode Fitted group aggregates are saved lookups, so inference does not need other group rows. Pandas similarity rendering can depend on neighboring datetimes and reports global; legacy unpinned similarity stays unknown.
ModelBasedSelection, feature_selection Prediction uses saved selected columns. The facade delegates inspection to its fitted concrete selector; it does not select features again.
IQR, ZScore, EllipticEnvelope Saved limits or detector models filter rows. Normal model prediction rejects a request that would lose rows; these steps are not automatically skipped.
count_vectorizer, tfidf_vectorizer, hashing_vectorizer, tokenizer Reuse learned vocabulary/IDF or saved analyzer settings. Outputs are dense. Custom callbacks, native estimator cache mutations and empty-output dtypes need their own diagnostic evidence.
H3Index Saved coordinate names and resolution drive the existing H3 calculation. Install and pin the optional h3 dependency in the scoring environment; the built-in artifact does not automatically capture that package pin. Pandas empty-output dtype differences remain visible.

global can therefore describe error handling as well as population statistics. For Box-Cox, the valid row in [2, -1] can remain untransformed when the negative neighbor makes the whole request fail, whereas [2] alone is transformed. The probe reports requires_context; it does not silently replace this behavior or claim singleton requests are equivalent. Correct invalid inputs and preserve request boundaries when using these modes.

These pandas/Polars declarations do not make additional steps eligible for Spark workers or serving endpoints. Saved-state validation also does not certify every value for artifact serialization: for example, native Decimal settings may be accepted by an applier but remain unsupported by sealed-model hashing.

The same boundary applies to temporal scalar values saved in snapshots: the semantic hash rejects unsupported dates, times, durations and periods rather than silently treating different values as equal. Native passthrough apply and binary-payload checks are separate from diagnostic state hashing. NumPy temporal arrays retain their supported array encoding; no new scalar codec is implied.

A skipped fitted step has no apply checks and reports state_validation: unavailable. An unrecorded split marker has no report entry at all. Neither result is a successful test of running that training operation on individual prediction rows.

Built-in owners can inspect saved pandas/Polars state through validate_inference_state. The probe calls that hook on a detached artifact before the existing apply; malformed state produces invalid_step_contract. Learned arrays, fixed bin edges and selected columns are checked, not learned again. This hook is separate from the stricter fitted-state/configuration contracts used for Spark workers. Your custom preprocessing still runs through its existing saved apply function; there is no second implementation of its transformation to maintain.

Run this after training/loading and when changing custom preprocessing or its dependencies, or enable the optional training report above. It is not a separate Bundle task, production monitor, accuracy evaluation, drift check or performance-loss policy. It does not call the model's prediction method. It tests detached preprocessing state; module globals and external services accessed by trusted custom code are not isolated.

See preprocessing placement, the Databricks Python SDK, and Bundle distributed inference for their separate execution requirements.

Runnable history continuation

This example fits a lag feature on ordered observations, reloads the artifact, and scores two new requests. history_mode="carry" saves the training tail; passing the returned history advances that tail without changing the artifact. The example uses one stream. For multiple entities, set group_by and supply ordered observations for each entity.

import json
from pathlib import Path

import numpy as np
import pandas as pd

from skyulf.data.dataset import SplitDataset
from skyulf.inference.fitted_pipeline import load_pipeline, save_pipeline
from skyulf.inference.pipeline_scoring import score_pipeline_with_history
from skyulf.inference.preprocessing_probe import probe_fitted_preprocessing
from skyulf.pipeline import SkyulfPipeline

observations = pd.DataFrame({
    "time": range(10),
    "value": np.arange(1.0, 11.0),
    "target": np.arange(1.0, 11.0) * 3,
})
pipeline = SkyulfPipeline({
    "preprocessing": [
        {"name": "lag", "transformer": "LagFeatures", "params": {
            "columns": ["value"], "lags": [1], "sort_by": "time",
            "history_mode": "carry",
        }},
        {"name": "fill", "transformer": "SimpleImputer", "params": {
            "columns": ["value_lag_1"], "strategy": "mean",
        }},
        {"name": "drop_clock", "transformer": "DropMissingColumns", "params": {
            "columns": ["time"], "missing_threshold": None,
        }},
    ],
    "modeling": {"type": "linear_regression"},
})
pipeline.fit(
    SplitDataset(train=observations.iloc[:8], test=observations.iloc[8:]),
    target_column="target",
)
artifact_path = Path("artifacts/history_demo")
save_pipeline(pipeline, artifact_path)
artifact = load_pipeline(artifact_path)
requests = pd.DataFrame({"time": [10, 11], "value": [11.0, 12.0]})

first = score_pipeline_with_history(requests.iloc[:1], artifact)
# JSON round-trip represents transporting the proposed state to another call.
state = json.loads(json.dumps(first.history))
second = score_pipeline_with_history(
    requests.iloc[1:], load_pipeline(artifact_path), history_state=state,
)
expected = score_pipeline_with_history(requests, artifact)
np.testing.assert_allclose(
    pd.concat([first.frame, second.frame])["prediction"],
    expected.frame["prediction"],
)
assert second.history == expected.history

# Independent row/chunk comparisons cannot stand in for this history session.
context_report = probe_fitted_preprocessing(artifact, requests)
assert context_report["status"] == "requires_context"
assert context_report["steps"][1]["status"] == "not_run"

After each successful request, commit its predictions and proposed history in one transaction. A later request must read that committed history. If either write fails, retain the previous state and retry the same request from it. Serialize requests for a stream or use compare-and-swap to reject a stale writer; the function cannot detect another process publishing competing state.

Use bootstrap_history=True only when the first request is a complete initial observation snapshot and should replace the training seed. It cannot accompany history_state. Preserve the configured ordering, unique entity/time keys and history limits; late or repeated rows are rejected. Complete-group custom callbacks still require complete groups in each frame, and custom windows still require their own caller-supplied history. This built-in continuation does not make those arbitrary callbacks stateful.