Standalone inference bundles
The bundle API packages a fitted feature pipeline and a Python
estimator with an explicit inference contract. It supports local pandas/Polars
prediction and native Spark FE followed by Python model inference on Spark workers.
Importing skyulf.inference requires neither PySpark nor MLflow.
Start with How inference works for training and inference diagrams and a worked example of reusing the same fitted FE and model.
Fitted pipeline or portable bundle?
Use FittedPipelineArtifact from skyulf.inference.fitted_pipeline to save and
restore the complete pandas/Polars pipeline with its recorded engine and schemas.
save_pipeline, load_pipeline and predict_pipeline use that format. It carries
the persisted kind="local_pipeline" and execution_scope="whole_frame_local"
values for compatibility; those strings do not restrict it to a workstation.
Use InferenceBundle for the explicitly portable preprocessing state and
predict_local/predict_spark API described here. These two artifact formats are
not interchangeable. Preprocessing context explains
when a complete frame, groups or carried history are required.
Raw data and prepared features
Choose the input stage when building a bundle:
input_stage |
Expected input | Execution |
|---|---|---|
raw |
Original feature columns | Apply saved FE once, then predict |
features |
Already transformed model features | Predict directly |
Both bundles contain the same frozen FE state and estimator. Their declared stages differ. A stage is an explicit caller contract: numeric values alone cannot reveal whether a column has already been scaled. Keep raw and prepared data on the corresponding path.
from pathlib import Path
from tempfile import TemporaryDirectory
import numpy as np
import pandas as pd
from skyulf.data.dataset import SplitDataset
from skyulf.inference import build_bundle, load_bundle, predict_local, save_bundle
from skyulf.pipeline import SkyulfPipeline
training = pd.DataFrame({"amount": [1.0, 2.0, 3.0], "target": [10.0, 20.0, 30.0]})
pipeline = SkyulfPipeline({
"preprocessing": [
{"name": "fill", "transformer": "SimpleImputer",
"params": {"columns": ["amount"], "strategy": "mean"}},
{"name": "scale", "transformer": "StandardScaler",
"params": {"columns": ["amount"]}},
],
"modeling": {"type": "linear_regression"},
})
pipeline.fit(SplitDataset(train=training, test=training.head(0)), target_column="target")
raw = pd.DataFrame({"amount": [4.0, np.nan]})
bundle = build_bundle(pipeline, input_stage="raw", feature_order=("amount",))
with TemporaryDirectory() as temporary:
destination = Path(temporary) / "model"
save_bundle(bundle, destination)
restored = load_bundle(destination)
output = predict_local(raw, restored)
np.testing.assert_allclose(output["prediction"], [40.0, 20.0], atol=1e-12)
prepared = pipeline.feature_engineer.transform(raw)
features_bundle = build_bundle(pipeline, input_stage="features", feature_order=("amount",))
np.testing.assert_allclose(predict_local(prepared, features_bundle)["prediction"],
output["prediction"], atol=1e-12)
The repository also includes skyulf-core/examples/inference_bundle.py. Run
it from the root with .venv/Scripts/python.exe skyulf-core/examples/inference_bundle.py.
Add --bundle-dir path/to/new-model to retain the artifact. An existing
destination is rejected, so an earlier package cannot be overwritten accidentally.
Input and output contracts
A successful SkyulfPipeline.fit records two schemas: original training
features with the target excluded, and the actual features supplied to model
training. They contain names and dtypes only, never sample rows. A failed
replacement fit clears this metadata along with the fitted model.
feature_order must exactly match the recorded model order. predict_local
rejects missing, extra, duplicate or reordered columns and incompatible dtypes.
It does not sort columns or silently cast them. Equivalent pandas/Polars numeric
and boolean dtype labels are normalized; an integer feature is still distinct
from a floating-point feature. Select/cast columns explicitly before calling
when the source schema differs intentionally.
Supply feature columns only. Extra targets or identity columns are rejected.
The returned pandas DataFrame preserves a pandas input index; Polars input
receives a RangeIndex. The Spark runner carries explicit FrameSpec keys
separately from model features. Calling predict_local with a Spark frame
fails before any collection or local conversion.
Regression returns a float64 prediction column. Supported classifiers return
prediction and probability_0, probability_1, etc. bundle.classes records
the original labels in exactly that probability order; labels are not converted
into column names. Binary positive_label is the second estimator class, matching
the existing probability/threshold convention. Empty inputs return the declared
empty output schema.
Tuning's saved decision thresholds remain active by default, as in the existing
tuning applier. Thresholds saved by pipeline.optimize_thresholds(...) remain
opt-in: pass use_tuned_thresholds=True to build_bundle. The manifest retains
both saved sets and the selected source; opting in without saved pipeline
thresholds fails. Probabilities remain unchanged by threshold decisions.
Package contents and loading
Each new directory contains three fixed-name files:
| File | Contents |
|---|---|
manifest.json |
Version, stage, ordered schemas, class/threshold metadata, runtime versions and digests |
features.json |
The supported fitted FeatureEngineer state |
model.pkl |
The frozen fitted sklearn estimator |
The bundle holds immutable metadata and bytes. Later mutation of the training pipeline does not alter it. Building or predicting never fits a transformer or estimator. The model payload excludes the pipeline, training reports, runtime sessions and registry clients. Runtime requirements are a fixed list of package versions, not a copy of environment variables or connection configuration. An estimator can retain training observations as part of its own fitted state; K-neighbors models are one example. Bundling preserves that state and its size is subject to the model byte limit.
Only load bundles from trusted producers. The estimator uses pickle, which can execute Python during deserialization. Checksums detect corruption; they are not signatures and do not establish trust. Size, structural, payload-checksum and runtime checks run before the pickle loader. Model class, feature width, classes and fitted semantic digest are checked after deserialization.
The semantic digest covers the inference contract and learned state. Exact payload checksums also protect transported bytes, but changing only the pickle protocol does not change semantic identity. Neither digest promises portability across arbitrary library versions.
Loading requires the same Python major/minor and exact recorded skyulf-core,
scikit-learn, NumPy and SciPy versions. pandas/Polars versions are recorded for
provenance; the input schema remains the execution check. There is no automatic
dependency installation. Prepare a matching environment before loading.
The optional options=ExecutionOptions(...) argument to build/save/load/predict
sets the existing budgets: by default 8 MiB for manifest plus FE bytes and 256 MiB
for estimator bytes. Oversized file reads and pickle serialization are bounded.
These are wire-size checks, not total process-memory guarantees. A wider input
batch or a loaded estimator can use more memory than its serialized form.
Native Spark FE and worker model inference
predict_spark(..., mode="native_features") accepts a raw-input regression or
classification bundle and a Spark DataFrame. The fitted FE runs as native Spark expressions.
Only the prepared feature columns and row keys reach the Python workers;
the estimator receives the features in the saved training order. Pandas/NumPy
batches exist inside those workers, without collecting the dataset to the driver.
Training fits the model in one Python process; it does not waive the matching
runtime requirements above. Train/package and consume in compatible environments,
even when switching from pandas or Polars inputs to Spark inputs.
This example continues the local training example above. spark is an existing,
caller-owned SparkSession; the runner neither creates nor closes it:
from skyulf.core.execution import ExecutionOptions, FrameSpec
from skyulf.inference import predict_spark
incoming = spark.createDataFrame(
[(101, 4.0, "unused"), (102, None, "unused")],
"id long, amount double, extra string",
).repartition(2)
predictions = predict_spark(
incoming,
bundle,
frame_spec=FrameSpec(record_key_columns=("id",)),
options=ExecutionOptions("spark", python_batch_rows=2),
mode="native_features",
)
# This small example materializes two output rows; the runner does not collect them.
rows = predictions.orderBy("id").collect()
assert [row.id for row in rows] == [101, 102]
np.testing.assert_allclose([row.prediction for row in rows], [40.0, 20.0], atol=1e-12)
The runnable skyulf-core/examples/spark_batch_inference.py example trains a
small pandas classification model, builds a bundle, and runs the same keyed
batch through either mode:
.venv-spark/Scripts/python.exe skyulf-core/examples/spark_batch_inference.py --mode native_features
.venv-spark/Scripts/python.exe skyulf-core/examples/spark_batch_inference.py --mode python_pipeline
Both modes return prediction, probability_0 and probability_1 in the
declared class order. A production job can replace the final show() action
with a Delta-table write or another caller-owned sink.
Production consumers receive a Spark DataFrame and can select their own action
or output sink. Output rows have no guaranteed physical order; match them by
the declared unique, non-null keys. Composite integer/string/boolean keys are
supported; floating-point and date/time keys are rejected by this first runner.
Keys must not also be model features. Incoming column names must not collide
with prediction output names. Leave FrameSpec.target unset. Unused input
columns are projected away, while the relative order and dtypes of required
feature columns are validated rather than silently corrected.
Native FE output must also match the recorded model-feature dtypes before any validation action runs. Some local/Spark operations promote numeric types differently: a local integer column without missing values can remain integer after mean imputation, while Spark's replacement expression produces double. That bundle is rejected instead of casting silently. Normalize numeric types explicitly before training and use the same input types for inference when a cross-engine pipeline requires floating-point features.
For this first worker runner, integral/boolean model features must have a
non-nullable Spark schema. Arrow can convert a nullable integer batch to pandas
float when it contains nulls, losing precision for large integers before the
model sees it. Nullable integral/boolean feature schemas are rejected eagerly,
even if their present rows happen to contain no nulls. Floating-point features
can retain null/NaN behavior. Row keys have a separate distributed non-null
check and can retain nullable schema metadata. The python_pipeline path also
rejects nullable integral/boolean raw inputs before Arrow, because Python FE
would otherwise receive a widened batch before its frozen imputer or scaler
could run. Broader nullable feature transport is part of the later worker
validation gate.
The model is loaded once per worker iterator invocation and reused over its prediction chunks. Task retries or later actions may load it again. The worker receives model bytes and inference metadata, without a SparkSession, registry client or tracking connection. Prepare matching dependencies on driver and workers; the runtime contract is checked in both places.
python_batch_rows limits the rows passed to an individual model prediction.
Spark's spark.sql.execution.arrow.maxRecordsPerBatch separately controls Arrow
transport batches. Set that Spark configuration on your session when needed;
the runner does not change it. Neither row limit is a byte-memory guarantee.
Input-key and FE preservation checks can execute bounded validation actions
before prediction; prediction itself remains a lazy Spark computation.
Validation does not freeze a changing source. The caller owns any snapshot or
persist policy needed to keep validation and later actions on the same input.
The distributed runners accept input_stage="raw" with regression or
classification. Prepared-feature bundles and streaming remain unsupported.
Classification output includes prediction plus probability_* columns in the
manifest class order. Saved tuning or pipeline thresholds are applied in the
same precedence order as local inference. The Python FE worker mode is
available for the portable row-independent path. Each process needs the recorded
runtime dependencies; validate packaging and worker environments on the actual
Databricks or Spark Connect compute before running a production workload.
Current support and legacy adapters
The first version accepts a successfully fitted standalone SkyulfPipeline
with recorded schemas, primitive numeric/boolean features, supported sklearn
regression/classification behavior and portable FE. Currently that FE is
SimpleImputer mean/constant and StandardScaler, including an empty chain.
Classifiers must expose class probabilities. Custom appliers and unsupported
FE nodes require a separate adapter; they are not serialized as an opaque
whole pipeline. Training-only split nodes in the FE chain are therefore not
portable yet; an explicit SplitDataset can supply training/test partitions.
Saving fitted transformations alongside the model already exists in Skyulf. The new bundle adds an explicit execution contract for local and distributed consumers; it does not relearn or replace those fitted transformations.
| Existing artifact | Saved information and current consumer | New bundle bridge |
|---|---|---|
Standalone SkyulfPipeline.save() pickle |
The pipeline object, fitted FE and model; SkyulfPipeline.load(...).predict(...) |
Load with the existing API, then call build_bundle if recorded schemas and supported FE are present |
Backend job artifact, normally .joblib |
Model, fitted FeatureEngineer, target/drop metadata, model feature order/dtypes and training engine; DeploymentService |
Not a bundle input; use its existing backend loader |
| Estimator-only pickle | Whatever the producer saved in the estimator | External preprocessing is not recovered automatically; a fitted pipeline and its input contract are needed |
The backend serving path already transforms raw input and aligns the result to the saved training feature order. Some serving choices, including enabled tuned thresholds and request overrides, are resolved outside that artifact. Its adapter must preserve that behavior and target-label decoding as well as the fitted model.
Existing SkyulfPipeline.save/load pickle behavior is unchanged. A standalone
pipeline saved after schema capture can be loaded through that API and passed
to build_bundle. Historical standalone artifacts without captured schemas
must be fitted successfully again; the bundle does not invent missing column
names or types. Backend artifact dictionaries are rejected. Their explicit
adapter would need to preserve threshold and target-label behavior; no implicit
conversion is performed.
Changing the inference engine does not make every fitted Python transformer a native Spark operation. For example, a future binning adapter must use the saved training bin boundaries; it must not discover new boundaries from each inference batch. The native path requires an implemented Spark applier and a supported state codec. The Python-worker path requires explicit support for independent batches and row preservation. Rolling/lag and transformations that need neighboring rows cannot be made correct merely by putting them inside a worker batch. Unsupported combinations fail explicitly; there is no automatic conversion of a whole Spark dataset to local pandas or Polars.
MLflow is a separate packaging/integration layer. Logging only an estimator does not include external feature engineering; log a fitted pipeline or provide a model wrapper that runs it. MLflow's Spark UDF executes Python model inference through pandas batches; it does not compile arbitrary Python transformations into native Spark expressions. Skyulf's MLflow adapter packages the selected artifact contract. The certified fitted-pipeline pyfunc route has separate admission checks from this portable bundle format. See the Databricks Bundle guide for generated jobs, source snapshots and publication.