Files
sientia-dataops-laborious_t…/docs/model-flavors.md
vitor-aignosi 68600e7b31
Some checks failed
Quality gate / quality-gate (push) Has been cancelled
Add docs
2026-09-01 13:28:13 -03:00

157 lines
9.0 KiB
Markdown

# 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.
```json
{
"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 | `mlflow.sklearn.load_model(<uri>)` — the loader fetches what it needs |
| `pyfunc` | yes | yes | `mlflow.pyfunc.load_model(<uri>)`; the wrapper path downloads the artifact instead and unwraps `._model_impl.python_model` |
| `joblib` | yes | yes | downloads the same artifact and `joblib.load`s 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_dir` inserts `<artifact>/code` at the front of `sys.path`, **only when that
directory exists**, and only once (an entry already present is not duplicated).
- `download_artifact_code_dir` is what feeds it on the download paths, and it is deliberately cheap:
the remote listing (`list_artifacts(run_id, '<artifact>')`) decides first, so a run without `code`
downloads **nothing**, and a run with it downloads only `<artifact>/code` — a handful of `.py`
files, never the model's weights.
- `download_model` runs that step first, for every flavor and both of its branches, *before* any
model is loaded — the insert has to be in place before the loader unpickles.
- The models themselves are not pre-downloaded: `mlflow.sklearn.load_model` and
`mlflow.pyfunc.load_model` are handed the model URI and fetch what they need. `joblib` downloads
its artifact because it must read the pickle, and adds the entry again inside `_load_model_pickle`
through the same helper, so the flavor works when called directly too.
- 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.
The directory `download_artifact_code_dir` downloaded into is also what `download_model` returns
next to the model — `tuple[Any, str | None]`, the second element being the artifact directory the
retrain path re-logs `code/` from, or `None` when the artifact has no `code/`. It falls back to
whatever the loader downloaded on its own, which is a real artifact directory for `joblib` and for
the wrapper branch. `load_predict_model` and `load_transform_model` stay the **single** place where
the flavor is decided: `download_model` has no `joblib` branch, it delegates by `model_type`. Its
only remaining branch is `load_wrapper`, which is derived from `flavor == 'pyfunc'` but is otherwise
orthogonal to the flavor.
> `sys.path` is 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 as `predict(context, model_input)`.
- Wrapper (`download_model(load_wrapper=True)`): downloads to `./tmp/artifacts/<model>/<artifact>`
via `dowload_artifacts`, loads as pyfunc, then unwraps `._model_impl.python_model`. Inside such an
artifact the real pickle is **nested**`<artifact>/artifacts/stacking_model.pkl` for predict,
`<artifact>/artifacts/training_transformer.pkl` for transform (`PREDICTION_COMPRESSED_PATH` /
`TRANSFORMED_COMPRESSED_PATH`). That nesting is why the `joblib` listing 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>)`, then
`joblib.load(<artifact>/model.pkl)`. `model.pkl` is `MODEL_PICKLE_NAME`, the same constant
`log_model` writes; another `.pkl` directly 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's `code/` to `<artifact>/code`.
- **`code/` goes on `sys.path`** before the load, when present — see the section above; the joblib
flavor is where that started, and it is now shared by all three.
- **No `MLmodel` is written**, so only this runtime reads the artifact back —
`mlflow.sklearn.load_model` and `models:/<name>/production` both fail on it. A model retrained in
joblib stays joblib.
- **The pickled object must expose `predict(data)`**, plus `fit(data)` if retrained. A pyfunc
`PythonModel` wrapper expecting `predict(context, model_input)` raises `TypeError`.
## Switching a model to `joblib`
The runtime does not migrate documents; editing `models` is an operator step.
1. **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.
2. **Edit the document**`predict_flavor` and/or `transform_flavor` to `'joblib'`. Imported models
arrive ready: `import_model` writes it itself, see [`model-import.md`](model-import.md) § 12.
3. **Check the first retrain** — the new run must carry `prediction_model/model.pkl` and, when the
source had one, `prediction_model/code/`.
4. **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.