aiml
SearchMy learning
Menu

Lesson 11 of 19Module 4

Export a full-precision inference bundle

Keep the trained scaler and classifier together without rounding serving parameters.

Keep the trained scaler and classifier together without rounding serving parameters. A local Rice-classifier lab with explicit failure checks.

Prerequisites

The reproducible supervised baseline and training-run artifact lessons; Python dictionaries and JSON.

Preparation and term definitions

By the end, you can

  • Export the unrounded scaler, classifier parameters and named-feature contract together.
  • Verify exact parameter serialization and validation-label parity against the source predictor.
  • Distinguish an inference bundle from a rounded report and a resumable checkpoint.
Runtime
Python 3.12+
Dependencies
Standard library only

Lab code, tests and fixtures (ZIP, 2.4 MB)Quiz check your understanding

Progress is saved in this browser.

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

Training rowsFit scaler +logistic modelCheck validationlabel parityUnroundedinference bundleValidation rowsrowsPredictionmismatchrefuseTraining rowsFit scaler +logistic modelCheck validationlabel parityUnroundedinference bundleValidation rowsPredictionmismatch
Training rows fit both scaler and classifier. Validation rows check label parity before the unrounded bundle is serialized. A mismatch stops export.

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.

Run the focused export exercise and its regression testsTerminal · bash
python exercise.py export
python -m unittest -v test_pipeline.py

Inspect 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.

Actual export exercise report from the previously trained Rice candidate, including its recorded result and refusal case.
Actual local execution using a previously trained candidate. This exercise does not perform new training. Full-resolution capture · Raw result · Command and hashes.
Read the actual pipeline.py implementation
pipeline.pySource · pythonDownload
"""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
Recorded local export exerciseRecorded output · textDownload
{
  "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?

A colleague keeps the learned weights but recomputes the scaler means on incoming serving requests. What happens?

Choose one answer, then check your reasoning.

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

Written by Arjun Prabhulal.

Module 4

Validate, register and serve a candidate