Problem
When you need this. Export a bundle when inference must reuse the trained preprocessing and parameters without report-only rounding.
The training-run lesson retains an evaluation report, but that report rounds its weights and scaler values. Serving those rounded values can change a prediction near the decision boundary. You will export the trained parameters without that extra rounding, retain their feature contract, and inspect the resulting bundle.
This continuation returns to the Rice classifier from the supervised baseline. It does not use the synthetic user-return count feature from the earlier feature-history lessons. The included candidate comes from a new CPU training execution; the original baseline recordings are preserved.
Concepts
A scaler stores the training-set mean and standard deviation for each feature. For each measurement, inference computes (value − mean) / std, multiplies the standardized values by their learned weights, and adds the bias. The fixed predictor chooses Cammeo when the resulting value z is at least zero, otherwise Osmancik. Its sigmoid output is a model probability estimate, not a calibration result.
A bundle keeps the scaler, classifier and ordered feature names together. Copying the weights without their scaler changes the function. Here, full precision means retaining the Python floating-point values through JSON serialization without manually rounding them to four decimals. It does not mean exact real-number arithmetic or a resumable training checkpoint.
Architecture
export() fits preprocessing and the classifier on training rows, then checks validation labels against the original predictor before serializing the bundle. The gate and registry loader subsequently reload those bytes. Dataset, split salt, training settings and implementation hashes identify the setup. The loader reads the complete CSV; held-out test rows are not used for fitting, tuning or scoring.
Working code
Download the complete lab, extract it, and enter mlops-registry-serving/. Use Python 3.12 or later; there are no third-party dependencies. The ZIP includes the candidate, data, source and tests.
python exercise.py export
python -m unittest -v test_pipeline.pyInspect the saved bundle before training again
Run python exercise.py export. This verifies the saved candidate against the separately retained digest in the run result, validates its dimensions, and prints its feature order, means, standard deviations, weights and bias. It then removes the coefficients in a temporary in-memory copy and records the refusal.
The bundle contains 7 features. Compare the printed area mean with its corresponding coefficient: the mean belongs to preprocessing, while the coefficient multiplies the standardized value. Both are required by the predictor.
Export a fresh candidate when you change the trainer
python run.py performs fresh CPU training, export, validation, temporary registration and actual loopback HTTP inference. It leaves the bundled recordings unchanged. --record deliberately replaces them. Use a new source/data identity when changing inputs; a matching score does not make a new execution the historical run.
The precision regression independently retrains the source model and compares every reloaded mean, standard deviation, weight and bias exactly. It also compares labels on all validation rows. Comparing labels alone could miss a small parameter change that leaves this finite set on the same side of the boundary.

Read the actual pipeline.py implementation
"""Local Rice inference export, validation gate and digest-selected registry.
No external packages. This is a trusted local filesystem exercise, not MLflow.
"""
import hashlib
import json
import math
import re
from pathlib import Path
from vendor import baseline
HERE = Path(__file__).resolve().parent
CONTRACT = 'rice-measurements-v1'
POLICY = {'accuracy': [90, 100], 'class_recall': [85, 100]}
def encode(value):
return (json.dumps(value, sort_keys=True, separators=(',', ':'),
allow_nan=False) + '\n').encode()
def digest(data):
return hashlib.sha256(data).hexdigest()
def pairs(items):
result = {}
for key, value in items:
if key in result:
raise ValueError('duplicate JSON key')
result[key] = value
return result
def decode(data):
return json.loads(data, object_pairs_hook=pairs,
parse_constant=lambda _: (_ for _ in ()).throw(ValueError('nonfinite JSON')))
def number(value):
try:
finite = type(value) in (float, int) and math.isfinite(value)
except OverflowError:
finite = False
if not finite:
raise ValueError('expected a finite non-Boolean number')
return value
def validate(bundle):
if not isinstance(bundle, dict):
raise ValueError('expected a bundle object')
if (type(bundle.get('format')) is not int or bundle.get('format') != 1 or bundle.get('contract') != CONTRACT
or bundle.get('features') != baseline.FEATURES
or bundle.get('labels') != baseline.LABELS
or bundle.get('positive') != baseline.POSITIVE):
raise ValueError('unsupported bundle contract')
for field in ('weights', 'mean', 'std'):
values = bundle.get(field)
if not isinstance(values, list) or len(values) != len(baseline.FEATURES):
raise ValueError('wrong parameter dimensions')
for value in values:
number(value)
if any(value <= 0 for value in bundle['std']):
raise ValueError('nonpositive standard deviation')
number(bundle.get('bias'))
return bundle
def predict(bundle, request, version):
if not isinstance(request, dict) or set(request) != {'contract', 'features'}:
raise ValueError('expected contract and features')
features = request['features']
if (request['contract'] != bundle['contract'] or not isinstance(features, dict)
or set(features) != set(bundle['features'])):
raise ValueError('feature contract mismatch')
values = [number(features[name]) for name in bundle['features']]
scaled = [(v - m) / s for v, m, s in zip(values, bundle['mean'], bundle['std'])]
z = bundle['bias'] + sum(w * v for w, v in zip(bundle['weights'], scaled))
if not all(math.isfinite(v) for v in scaled) or not math.isfinite(z):
raise ValueError('nonfinite computation')
probability = baseline.sigmoid(z)
label = baseline.POSITIVE if z >= 0 else baseline.LABELS[1]
return {'version': version, 'contract': CONTRACT, 'label': label,
'positive_probability': probability}
def request_for(row):
return {'contract': CONTRACT, 'features': dict(zip(baseline.FEATURES, row['x']))}
def export():
parts = baseline.split_rows(baseline.load_rows())
scaler = baseline.fit_scaler(parts['training'])
model = baseline.train_logistic(parts['training'], scaler)
bundle = {'format': 1, 'contract': CONTRACT, 'features': baseline.FEATURES,
'labels': baseline.LABELS, 'positive': baseline.POSITIVE,
'weights': model['weights'], 'bias': model['bias'], **scaler,
'training': {'dataset_sha256': baseline.DATA_SHA256,
'split_salt': baseline.SALT, 'epochs': baseline.EPOCHS,
'learning_rate': baseline.LEARNING_RATE,
'trainer_sha256': digest(Path(baseline.__file__).read_bytes()),
'exporter_sha256': digest(Path(__file__).read_bytes())}}
validate(bundle)
# Match the source predictor on every validation row before retaining bytes.
version = digest(encode(bundle))
for row in parts['validation']:
if predict(bundle, request_for(row), version)['label'] != baseline.predict_logistic(model, scaler, row):
raise ValueError('export prediction mismatch')
return encode(bundle)
def quality_gate(data, expected):
if digest(data) != expected:
raise ValueError('candidate digest mismatch')
bundle = validate(decode(data))
training = bundle.get('training', {})
required = {'dataset_sha256': baseline.DATA_SHA256, 'split_salt': baseline.SALT,
'epochs': baseline.EPOCHS, 'learning_rate': baseline.LEARNING_RATE,
'trainer_sha256': digest(Path(baseline.__file__).read_bytes()),
'exporter_sha256': digest(Path(__file__).read_bytes())}
if training != required:
raise ValueError('unsupported training identity')
rows = baseline.split_rows(baseline.load_rows())['validation']
matrix = {label: {other: 0 for other in baseline.LABELS} for label in baseline.LABELS}
for row in rows:
result = predict(bundle, request_for(row), expected)
matrix[row['label']][result['label']] += 1
correct = sum(matrix[label][label] for label in matrix)
enough = lambda a, b, ratio: b > 0 and a * ratio[1] >= b * ratio[0]
passed = enough(correct, len(rows), POLICY['accuracy']) and all(
enough(matrix[label][label], sum(matrix[label].values()), POLICY['class_recall'])
for label in matrix)
return {'candidate_sha256': expected, 'partition': 'validation', 'policy': POLICY,
'membership_sha256': digest(encode(sorted(row['key'] for row in rows))),
'rows': len(rows), 'correct': correct, 'confusion': matrix, 'passed': passed}
def registered_file(root, version):
if not isinstance(version, str) or not re.fullmatch('[0-9a-f]{64}', version):
raise ValueError('select an explicit SHA-256 version')
root = Path(root)
if root.is_symlink():
raise ValueError('symlink registry root')
return root / (version + '.json')
def register(root, data, expected):
record = quality_gate(data, expected)
if not record['passed']:
raise ValueError('candidate failed quality policy')
path = registered_file(root, expected)
path.parent.mkdir(parents=True, exist_ok=True)
if path.is_symlink():
raise ValueError('symlink registration')
try:
with path.open('xb') as stream:
stream.write(data)
except FileExistsError:
if path.read_bytes() != data:
raise ValueError('conflicting or incomplete registration') from None
return record
def load(root, expected):
path = registered_file(root, expected)
if path.is_symlink():
raise ValueError('symlink registration')
data = path.read_bytes()
if digest(data) != expected:
raise ValueError('registered bytes changed or incomplete')
return validate(decode(data))
def batch(bundle, requests, version):
if not isinstance(requests, list) or not 1 <= len(requests) <= 64:
raise ValueError('batch size must be 1..64')
# Return only a complete list. A bad row raises before any output is returned.
return [predict(bundle, request, version) for request in requests]Read the complete recorded export output
{
"execution_mode": "local exercise using a previously trained candidate",
"candidate_sha256": "a4692350c5e6d1f231eea8c5eb3354c24276870e73a60922a10abb07abffa5f6",
"contract": "rice-measurements-v1",
"features": [
"area",
"perimeter",
"major_axis_length",
"minor_axis_length",
"eccentricity",
"convex_area",
"extent"
],
"mean": [
12687.950075075076,
454.8322726412936,
189.06824767768563,
86.32013676474402,
0.8872013573308248,
12974.498123123123,
0.6617490176458258
],
"std": [
1741.9744137739904,
35.83262834885613,
17.5649530363495,
5.73547149053099,
0.02082227216699669,
1787.5210017866389,
0.07703667354512676
],
"weights": [
0.793923485855369,
1.064060433108002,
1.2288946549981554,
-0.09488919449664353,
1.3096679579007047,
0.890754745411753,
0.09708883758206581
],
"bias": -0.731549216459079,
"invalid_dimensions": "wrong parameter dimensions"
}The exercise driver shows each operation and cleanup. The fresh-training driver and training run record document how the saved candidate was created. Adding --record to an exercise deliberately replaces its result and generated report; it does not refresh the screenshot automatically.
Failure cases
| Trigger | Required boundary and test |
|---|---|
| Invalid dimensions | validate() refuses unequal feature/parameter lengths; test_parameter_dimensions_and_scaler_refused exercises the check. |
| Wrong bundle type | Arrays, null and non-integer format values are refused by test_nonobject_bundle_and_wrong_format_types_refused. |
| Rounded parameters | test_full_precision_reload_matches_source_predictions compares every parameter with a fresh source-model fit, so a rounding change fails even when labels stay equal. |
The focused exercise records wrong parameter dimensions. It changes only a copy; the saved candidate stays intact. These are parameter and export checks. They do not establish calibrated probabilities or performance on future observations.
Deployment
The deployment unit introduced here is an inference bundle, not a running service. Keep its feature contract, scaler and weights together and retain its expected digest outside the registry directory. The next lesson evaluates those exact bytes before allowing registration.
Scaling
This exporter materializes the small dataset and model in one process. Larger models need an appropriate tensor/checkpoint format and storage strategy; this JSON format is deliberately limited to the small classifier. No serialization throughput or GPU performance has been measured.
Key takeaways
A colleague keeps the learned weights but recomputes the scaler means on incoming serving requests. What happens?
Read the explanation
The weights were trained for the retained training-set scaler. Recomputing means changes standardized inputs.
Best practices
Retain preprocessing with model parameters. Validate dimensions before calling code that uses zip. Compare the full parameter round trip, as well as predictions, when testing serialization.
References
- MLflow model-registry workflows explain versions and mutable aliases. This lab uses its own local registry.
- scikit-learn common pitfalls explains fitting preprocessing on training data and applying it consistently during inference.
- Python HTTP server documentation describes the adapter and its production limitations.
- UCI Rice dataset supplies the measurement data under CC BY 4.0.