QTZPOC-20: Unify artifact code directory handling for all model flavors and standardize joblib pickle path Implemented a robust mechanism to ensure an artifact's code/ directory is prepended to sys.path before loading for sklearn, pyfunc, and joblib flavors. sklearn and pyfunc now resolve MLflow URIs to local directories first to facilitate this sys.path modification. Standardized the joblib flavor to explicitly read and write model.pkl within the artifact directory. Updated documentation and added comprehensive tests to reflect these changes.
7.9 KiB
Model flavors
The flavor is how this runtime is told to deserialize a model. It is configuration, never
discovery: the value comes from the model document in the models collection and the code never
reads the artifact's MLmodel manifest to guess it.
{
"model_config": {
"transform_flavor": "sklearn",
"predict_flavor": "joblib",
"retention_minutes": 60,
"target": "SE"
}
}
predict_flavor drives load_predict_model (artifact prediction_model); transform_flavor drives
load_transform_model (artifact data_model). Both default to sklearn when the key is absent.
Accepted values
| Flavor | Read | Write (retrain) | How it loads |
|---|---|---|---|
sklearn |
yes | yes | resolves the URI to a local directory, then mlflow.sklearn.load_model(<dir>) |
pyfunc |
yes | yes | resolves the URI to a local directory, then mlflow.pyfunc.load_model(<dir>); the wrapper path also unwraps ._model_impl.python_model |
joblib |
yes | yes | downloads the same artifact and joblib.loads its <artifact>/model.pkl — no MLflow flavor loader involved |
Anything else raises ValueError with INVALID_FLAVOR_MESSAGE, before any artifact is resolved
or downloaded. The same message and the same three values govern reading and writing.
pytorch was accepted until this change and is now rejected like any other unknown value: torch is
not installed in the runtime, so mlflow.pytorch.load_model could never have loaded anything there.
There is no fallback between flavors. A failing loader propagates: one attempt, one cause. A model configured with the wrong flavor fails visibly instead of being rescued silently.
Same artifact, three readers
The flavor changes the reader, never the path. All three resolve the same two artifact directories of the Production run:
<run>/artifacts/
├── data_model/ ← transform_flavor loads this one
│ ├── MLmodel ← parsed by sklearn/pyfunc; ignored by joblib
│ ├── model.pkl ← what joblib.load reads
│ └── code/ ← put on sys.path by every flavor, when present
├── prediction_model/ ← predict_flavor loads this one
└── model_card/, *.csv
The run's artifacts/ root holds no .pkl — every model pickle lives one level down, inside its
artifact directory. Predict resolves that directory through the registry URI
(models:/<name>/production, which MLflow points at the same prediction_model); transform builds
the run artifact URI directly with get_model_uri, since the run is already resolved. Observability
is the same for the three: MODEL_READ_* on load, MODEL_WRITE_* on log_model, under the base
operation_type — no per-flavor metric, operation type or label.
code/ on sys.path, for every flavor
An artifact may ship the modules its own classes live in, under <artifact>/code/. Without that
directory on sys.path, unpickling raises ModuleNotFoundError — the artifact is intact, the class
is simply not importable.
MLflow's loaders do add it, but from the manifest: _add_code_from_conf_to_system_path acts only
when the MLmodel flavor config declares a code entry. An artifact carrying code/ on disk
without that entry — code logged with log_artifacts, a re-log that dropped the config, anything a
joblib retrain wrote — loads with no module path at all. So this runtime reads the directory,
which no manifest can misdescribe:
prepend_artifact_code_dirinserts<artifact>/codeat the front ofsys.path, only when that directory exists, and only once (an entry already present is not duplicated).- Because the insert must happen before the loader unpickles,
sklearnandpyfuncresolve their URI to a local directory first (mlflow.artifacts.download_artifacts) and the loader is handed that directory. Same bytes, same download count — MLflow would have downloaded it anyway. - The wrapper path (
download_model(load_wrapper=True)) already had its local directory and adds the entry itself;joblibadds it inside_load_model_pickle. - When the manifest does declare
code, MLflow inserts the same directory a second time. Harmless.
The entry is never removed: a loaded model may import lazily, long after the load returned.
sys.pathis process-global: two models embedding a package with the same name (utils/) on the same worker resolve to whichever entered first. Mitigation, if it bites: one worker per model.
sklearn
Default. A standard MLflow sklearn artifact: mlflow.sklearn.log_model writes MLmodel +
model.pkl, mlflow.sklearn.load_model reads it, and every outside consumer (MLflow UI, MLflow
serving) recognizes it as a model.
pyfunc
For models logged as mlflow.pyfunc, including the wrapper models this pipeline produces. Retrain
writes through the wrapper's own store_model(artifact_path=<artifact>, code_path=…). Two read
paths, and the second is orthogonal to the flavor:
- Plain:
mlflow.pyfunc.load_model(<uri>), called aspredict(context, model_input). - Wrapper (
download_model(load_wrapper=True)): downloads to./tmp/artifacts/<model>/<artifact>viadowload_artifacts, loads as pyfunc, then unwraps._model_impl.python_model. Inside such an artifact the real pickle is nested —<artifact>/artifacts/stacking_model.pklfor predict,<artifact>/artifacts/training_transformer.pklfor transform (PREDICTION_COMPRESSED_PATH/TRANSFORMED_COMPRESSED_PATH). That nesting is why thejobliblisting does not recurse.
joblib
For models whose .pkl is in the compressed form joblib.dump writes, which MLflow's pickle.load
does not read — the artifact is intact, but no flavor loader opens it.
- Read:
client.download_artifacts(run_id, <artifact>), thenjoblib.load(<artifact>/model.pkl).model.pklisMODEL_PICKLE_NAME, the same constantlog_modelwrites; another.pkldirectly inside the artifact is a fallback, in name order. - Write:
joblib.dump(model, '<artifact>/model.pkl', compress=3), plus a re-log of the source artifact'scode/to<artifact>/code. code/goes onsys.pathbefore the load, when present — see the section above; the joblib flavor is where that started, and it is now shared by all three.- No
MLmodelis written, so only this runtime reads the artifact back —mlflow.sklearn.load_modelandmodels:/<name>/productionboth fail on it. A model retrained in joblib stays joblib. - The pickled object must expose
predict(data), plusfit(data)if retrained. A pyfuncPythonModelwrapper expectingpredict(context, model_input)raisesTypeError.
Switching a model to joblib
The runtime does not migrate documents; editing models is an operator step.
- Inventory before the deploy — run predict/transform and note which models raise. There is no discovery afterwards: the failure names the loader, not the fix.
- Edit the document —
predict_flavorand/ortransform_flavorto'joblib'. Imported models arrive ready:import_modelwrites it itself, seemodel-import.md§ 12. - Check the first retrain — the new run must carry
prediction_model/model.pkland, when the source had one,prediction_model/code/. - Rollback is not just a revert — artifacts a joblib retrain already wrote stop being loadable, so it also means promoting the previous version back and reverting the documents' flavors.
Known gap: how the run id is resolved
get_model_run_id takes source.split('/')[2] of the registered version's source. Not confirmed
against the real tracking server: no scheme reproduced locally puts the run id there (a file store
yields '', S3 the bucket, mlflow-artifacts:/ the experiment id), yet predict works in production.
Every flavor that resolves a run goes through this line — confirm it with a read-only query
before relying on it.