QTZPOC-20

QTZPOC-20: Remove PyTorch from supported model flavors as it was never functional due to missing runtime dependencies.
This commit is contained in:
2026-08-27 14:58:43 -03:00
parent 853c83e662
commit dfb9deae1a
7 changed files with 70 additions and 73 deletions

View File

@@ -1109,8 +1109,8 @@ Legacy MinIO object layout (relative key):
## Model Flavors ## Model Flavors
`model_config.predict_flavor` / `model_config.transform_flavor` accept `sklearn`, `pyfunc`, `model_config.predict_flavor` / `model_config.transform_flavor` accept `sklearn`, `pyfunc` and
`pytorch` and `joblib`. The flavor is configuration, never discovered from the artifact, and there is `joblib`. The flavor is configuration, never discovered from the artifact, and there is
no fallback between flavors: a failing loader propagates. no fallback between flavors: a failing loader propagates.
`joblib` reads the artifact directly — download, first top-level `.pkl`, `joblib.load` — with the `joblib` reads the artifact directly — download, first top-level `.pkl`, `joblib.load` — with the

View File

@@ -24,11 +24,13 @@ reads the artifact's `MLmodel` manifest to guess it.
|---|---|---|---| |---|---|---|---|
| `sklearn` | yes | yes | `mlflow.sklearn.load_model(<uri>)` | | `sklearn` | yes | yes | `mlflow.sklearn.load_model(<uri>)` |
| `pyfunc` | yes | yes | `mlflow.pyfunc.load_model(<uri>)`; the wrapper path also unwraps `._model_impl.python_model` | | `pyfunc` | yes | yes | `mlflow.pyfunc.load_model(<uri>)`; the wrapper path also unwraps `._model_impl.python_model` |
| `pytorch` | yes | yes | `mlflow.pytorch.load_model(<uri>)` |
| `joblib` | yes | yes | downloads the artifact and `joblib.load`s its pickle — **no MLflow flavor loader involved** | | `joblib` | yes | yes | downloads the artifact and `joblib.load`s its pickle — **no MLflow flavor loader involved** |
Anything else raises `ValueError` with `INVALID_FLAVOR_MESSAGE`, **before** any artifact is resolved Anything else raises `ValueError` with `INVALID_FLAVOR_MESSAGE`, **before** any artifact is resolved
or downloaded. The same message and the same four values govern reading and writing. 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 **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. model configured with the wrong flavor fails visibly instead of being rescued silently.

View File

@@ -546,10 +546,6 @@ def patch_mlflow(mock_mlflow_models, joblib_flavor_artifacts):
mock_mlflow.pyfunc = MagicMock() mock_mlflow.pyfunc = MagicMock()
mock_mlflow.pyfunc.load_model = MagicMock(side_effect=mock_pyfunc_load_model) mock_mlflow.pyfunc.load_model = MagicMock(side_effect=mock_pyfunc_load_model)
# Mock pytorch.load_model
mock_mlflow.pytorch = MagicMock()
mock_mlflow.pytorch.load_model = MagicMock(return_value=mock_mlflow_models['predict_model'])
# Mock other mlflow methods that might be called # Mock other mlflow methods that might be called
mock_mlflow.set_tracking_uri = MagicMock() mock_mlflow.set_tracking_uri = MagicMock()
mock_mlflow.get_run = MagicMock( mock_mlflow.get_run = MagicMock(

View File

@@ -1124,7 +1124,7 @@ restores `sys.path` in teardown, because the flavor deliberately never removes w
**Assertions**: **Assertions**:
- Exactly one prediction row, `prediction_status='Good'`, value matching the artifact's model - Exactly one prediction row, `prediction_status='Good'`, value matching the artifact's model
- `download_artifacts` called once, with `'prediction_model'` - `download_artifacts` called once, with `'prediction_model'`
- No `mlflow.{sklearn,pyfunc,pytorch}.load_model` call names this model - No `mlflow.{sklearn,pyfunc}.load_model` call names this model
--- ---

View File

@@ -26,7 +26,7 @@ from laborious.workflows.predictions_batch import PredictionsBatch
base_query = 'SELECT timestamp, variable, value, created_at FROM predictions_schema.laborious_data WHERE model_id = {model_id}' base_query = 'SELECT timestamp, variable, value, created_at FROM predictions_schema.laborious_data WHERE model_id = {model_id}'
FLAVOR_LOADERS = ('sklearn', 'pyfunc', 'pytorch') FLAVOR_LOADERS = ('sklearn', 'pyfunc')
def build_input_data( def build_input_data(

View File

@@ -45,7 +45,7 @@ ARTIFACTS_PATH = './tmp/artifacts'
TRANSFORMED_COMPRESSED_PATH = 'artifacts/training_transformer.pkl' TRANSFORMED_COMPRESSED_PATH = 'artifacts/training_transformer.pkl'
PREDICTION_COMPRESSED_PATH = 'artifacts/stacking_model.pkl' PREDICTION_COMPRESSED_PATH = 'artifacts/stacking_model.pkl'
INVALID_FLAVOR_MESSAGE = "Invalid flavor. Use 'sklearn' or 'pyfunc' or 'pytorch' or 'joblib'." INVALID_FLAVOR_MESSAGE = "Invalid flavor. Use 'sklearn' or 'pyfunc' or 'joblib'."
def force_memory_release(logger: Logger): def force_memory_release(logger: Logger):
@@ -550,7 +550,7 @@ class MLFlowRepository(SientiaMonitoring):
Args: Args:
model_name (str): The name of the model to download from the registry. model_name (str): The name of the model to download from the registry.
metadata (dict[str, Any]): Metadata used for structured logging. metadata (dict[str, Any]): Metadata used for structured logging.
flavor (str): Model flavor ('pyfunc', 'sklearn', 'pytorch', 'joblib') flavor (str): Model flavor ('pyfunc', 'sklearn', 'joblib')
artifact_path (str | None): Path to compressed artifacts if model is compressed artifact_path (str | None): Path to compressed artifacts if model is compressed
Returns: Returns:
@@ -564,7 +564,7 @@ class MLFlowRepository(SientiaMonitoring):
`joblib.load`, which needs no `MLmodel` manifest. The artifact's `joblib.load`, which needs no `MLmodel` manifest. The artifact's
`code` directory, when present, is put on `sys.path` first. `code` directory, when present, is put on `sys.path` first.
""" """
if flavor not in ('pyfunc', 'sklearn', 'pytorch', 'joblib'): if flavor not in ('pyfunc', 'sklearn', 'joblib'):
raise ValueError(INVALID_FLAVOR_MESSAGE) raise ValueError(INVALID_FLAVOR_MESSAGE)
if flavor == 'joblib': if flavor == 'joblib':
@@ -584,10 +584,8 @@ class MLFlowRepository(SientiaMonitoring):
try: try:
if flavor == 'pyfunc': if flavor == 'pyfunc':
model = mlflow.pyfunc.load_model(model_uri) model = mlflow.pyfunc.load_model(model_uri)
elif flavor == 'sklearn':
model = mlflow.sklearn.load_model(model_uri)
else: else:
model = mlflow.pytorch.load_model(model_uri) model = mlflow.sklearn.load_model(model_uri)
except Exception as e: except Exception as e:
await self.emit_metric(metric_object=metrics.MODEL_READ_ERROR_COUNT, tags=core_labels) await self.emit_metric(metric_object=metrics.MODEL_READ_ERROR_COUNT, tags=core_labels)
raise e raise e
@@ -608,7 +606,7 @@ class MLFlowRepository(SientiaMonitoring):
Args: Args:
model_name (str): The name of the model to download. model_name (str): The name of the model to download.
metadata (dict[str, Any]): Metadata used for structured logging. metadata (dict[str, Any]): Metadata used for structured logging.
flavor (str): Model flavor ('sklearn', 'pyfunc', 'pytorch', 'joblib') flavor (str): Model flavor ('sklearn', 'pyfunc', 'joblib')
artifact_path (str | None): Path to compressed artifacts if model is compressed artifact_path (str | None): Path to compressed artifacts if model is compressed
Returns: Returns:
@@ -626,7 +624,7 @@ class MLFlowRepository(SientiaMonitoring):
directory, when present, is put on `sys.path` first. directory, when present, is put on `sys.path` first.
""" """
if flavor not in ('sklearn', 'pyfunc', 'pytorch', 'joblib'): if flavor not in ('sklearn', 'pyfunc', 'joblib'):
raise ValueError(INVALID_FLAVOR_MESSAGE) raise ValueError(INVALID_FLAVOR_MESSAGE)
latest_production_id = self.get_model_run_id(model_name=model_name, stage='Production') latest_production_id = self.get_model_run_id(model_name=model_name, stage='Production')
@@ -650,10 +648,8 @@ class MLFlowRepository(SientiaMonitoring):
try: try:
if flavor == 'sklearn': if flavor == 'sklearn':
model = mlflow.sklearn.load_model(model_uri) model = mlflow.sklearn.load_model(model_uri)
elif flavor == 'pyfunc':
model = mlflow.pyfunc.load_model(model_uri)
else: else:
model = mlflow.pytorch.load_model(model_uri) model = mlflow.pyfunc.load_model(model_uri)
except Exception as e: except Exception as e:
await self.emit_metric(metric_object=metrics.MODEL_READ_ERROR_COUNT, tags=core_labels) await self.emit_metric(metric_object=metrics.MODEL_READ_ERROR_COUNT, tags=core_labels)
raise e raise e
@@ -677,7 +673,7 @@ class MLFlowRepository(SientiaMonitoring):
model_name (str): Name of the model to download model_name (str): Name of the model to download
metadata (dict[str, Any]): Metadata used for structured logging. metadata (dict[str, Any]): Metadata used for structured logging.
model_type (str): Type of model ('predict' or 'transform') model_type (str): Type of model ('predict' or 'transform')
flavor (str): Model flavor ('sklearn', 'pyfunc', 'pytorch', 'joblib') flavor (str): Model flavor ('sklearn', 'pyfunc', 'joblib')
load_wrapper (bool): Whether to load wrapper load_wrapper (bool): Whether to load wrapper
Returns: Returns:
@@ -862,7 +858,7 @@ class MLFlowRepository(SientiaMonitoring):
metadata (dict[str, Any]): Metadata used for structured logging. metadata (dict[str, Any]): Metadata used for structured logging.
retention (int): Cache retention time in minutes (0 = no cache). retention (int): Cache retention time in minutes (0 = no cache).
model_type (str): Type of model ('predict' or 'transform') model_type (str): Type of model ('predict' or 'transform')
flavor (str): Model flavor ('sklearn', 'pyfunc', 'pytorch') flavor (str): Model flavor ('sklearn', 'pyfunc', 'joblib')
Returns: Returns:
Any: Model object. Any: Model object.
@@ -950,7 +946,7 @@ class MLFlowRepository(SientiaMonitoring):
model_name (str): Registered model name. model_name (str): Registered model name.
data (pd.DataFrame): Input data. data (pd.DataFrame): Input data.
retention (int): Cache retention in minutes. retention (int): Cache retention in minutes.
flavor (str): Model flavor ('sklearn', 'pyfunc', 'pytorch'). flavor (str): Model flavor ('sklearn', 'pyfunc', 'joblib').
metadata (dict[str, Any]): Metadata used for structured logging. metadata (dict[str, Any]): Metadata used for structured logging.
Returns: Returns:
pd.DataFrame | ndarray: Operation result. pd.DataFrame | ndarray: Operation result.
@@ -1228,7 +1224,7 @@ class MLFlowRepository(SientiaMonitoring):
Args: Args:
model_data (dict): Model holder with keys 'model' and optional 'artifact_path'. model_data (dict): Model holder with keys 'model' and optional 'artifact_path'.
flavor (str): Model flavor ('sklearn', 'pyfunc', 'pytorch', 'joblib'). flavor (str): Model flavor ('sklearn', 'pyfunc', 'joblib').
model_type (str): Artifact name, e.g., 'prediction_model' or 'data_model'. model_type (str): Artifact name, e.g., 'prediction_model' or 'data_model'.
metadata (dict): Metadata for structured logging. metadata (dict): Metadata for structured logging.
@@ -1257,8 +1253,6 @@ class MLFlowRepository(SientiaMonitoring):
model.store_model(artifact_path=model_type, code_path=code_path, to_disk=False) model.store_model(artifact_path=model_type, code_path=code_path, to_disk=False)
self.debug('Model uploaded successfully', metadata) self.debug('Model uploaded successfully', metadata)
elif flavor == 'pytorch':
mlflow.pytorch.log_model(model, model_type)
elif flavor == 'joblib': elif flavor == 'joblib':
with tempfile.TemporaryDirectory() as tmp_dir: with tempfile.TemporaryDirectory() as tmp_dir:
pkl_path = path.join(tmp_dir, 'model.pkl') pkl_path = path.join(tmp_dir, 'model.pkl')

View File

@@ -455,17 +455,6 @@ async def test_load_predict_model_pyfunc(mlflow, mlflow_repository):
) )
@pytest.mark.asyncio
async def test_load_predict_model_pytorch(mlflow, mlflow_repository):
result = await mlflow_repository.load_predict_model('test_model', {}, 'pytorch')
assert result == mlflow.pytorch.load_model.return_value
mlflow.pytorch.load_model.assert_called_once_with('models:/test_model/production')
mlflow_repository.observe_lag.assert_called_once_with(ANY, metrics.MODEL_READ_LAG, ANY)
mlflow_repository.emit_metric.assert_called_once_with(
metric_object=metrics.MODEL_READ_COUNT, tags=ANY
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_load_predict_model_error(mlflow_repository): async def test_load_predict_model_error(mlflow_repository):
with pytest.raises(ValueError) as e: with pytest.raises(ValueError) as e:
@@ -523,23 +512,6 @@ async def test_load_transform_model_pyfunc(mlflow, mlflow_repository):
) )
@pytest.mark.asyncio
async def test_load_transform_model_pytorch(mlflow, mlflow_repository):
mlflow_repository.get_model_run_id = MagicMock()
mlflow_repository.get_model_uri = MagicMock()
result = await mlflow_repository.load_transform_model('test_model', {}, 'pytorch')
validate_common_load_transform_model_mocks(mlflow_repository, 'test_model')
assert result == mlflow.pytorch.load_model.return_value
mlflow.pytorch.load_model.assert_called_once_with(mlflow_repository.get_model_uri.return_value)
mlflow_repository.observe_lag.assert_called_once_with(ANY, metrics.MODEL_READ_LAG, ANY)
mlflow_repository.emit_metric.assert_called_once_with(
metric_object=metrics.MODEL_READ_COUNT, tags=ANY
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_load_transform_model_error(mlflow_repository): async def test_load_transform_model_error(mlflow_repository):
mlflow_repository.get_model_run_id = MagicMock() mlflow_repository.get_model_run_id = MagicMock()
@@ -1108,20 +1080,6 @@ async def test_log_model_pyfunc(tmp_path, mlflow, mlflow_repository):
) )
@pytest.mark.asyncio
async def test_log_model_pytorch(mlflow, mlflow_repository):
model_data = {'model': MagicMock(), 'artifact_path': 'artifact_path'}
await mlflow_repository.log_model(
model_data, 'pytorch', 'prediction_model', metadata['metadata']
)
mlflow.pytorch.log_model.assert_called_once_with(model_data['model'], 'prediction_model')
mlflow_repository.observe_lag.assert_called_once_with(ANY, metrics.MODEL_WRITE_LAG, ANY)
mlflow_repository.emit_metric.assert_called_once_with(
metric_object=metrics.MODEL_WRITE_COUNT, tags=ANY
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_log_model_error(mlflow_repository): async def test_log_model_error(mlflow_repository):
model_data = {'model': MagicMock(), 'artifact_path': 'artifact_path'} model_data = {'model': MagicMock(), 'artifact_path': 'artifact_path'}
@@ -1777,7 +1735,7 @@ Tests for the joblib flavor on the model load and write paths
""" """
JOBLIB_PATH = 'laborious.utils.repository.model_repository.joblib' JOBLIB_PATH = 'laborious.utils.repository.model_repository.joblib'
FLAVOR_LOADERS = ('sklearn', 'pyfunc', 'pytorch') FLAVOR_LOADERS = ('sklearn', 'pyfunc')
@pytest.fixture @pytest.fixture
@@ -1889,7 +1847,7 @@ async def test_load_transform_model_joblib_resolves_run_once(
@patch(JOBLIB_PATH) @patch(JOBLIB_PATH)
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize('flavor', ['sklearn', 'pyfunc', 'pytorch']) @pytest.mark.parametrize('flavor', ['sklearn', 'pyfunc'])
async def test_load_predict_model_flavor_failure_propagates( async def test_load_predict_model_flavor_failure_propagates(
joblib_mock, mlflow, mlflow_repository, flavor joblib_mock, mlflow, mlflow_repository, flavor
): ):
@@ -1910,7 +1868,7 @@ async def test_load_predict_model_flavor_failure_propagates(
@patch(JOBLIB_PATH) @patch(JOBLIB_PATH)
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize('flavor', ['sklearn', 'pyfunc', 'pytorch']) @pytest.mark.parametrize('flavor', ['sklearn', 'pyfunc'])
async def test_load_transform_model_flavor_failure_propagates( async def test_load_transform_model_flavor_failure_propagates(
joblib_mock, mlflow, mlflow_repository, flavor joblib_mock, mlflow, mlflow_repository, flavor
): ):
@@ -2361,10 +2319,57 @@ async def test_log_model_invalid_flavor(joblib_mock, mlflow, mlflow_repository):
mlflow_repository.observe_lag.assert_not_called() mlflow_repository.observe_lag.assert_not_called()
@patch(JOBLIB_PATH)
@pytest.mark.asyncio
async def test_load_predict_model_rejects_pytorch(joblib_mock, mlflow, mlflow_repository):
with pytest.raises(ValueError) as exc_info:
await mlflow_repository.load_predict_model('test_model', {}, 'pytorch')
assert str(exc_info.value) == INVALID_FLAVOR_MESSAGE
mlflow.pytorch.load_model.assert_not_called()
joblib_mock.load.assert_not_called()
mlflow_repository.client.download_artifacts.assert_not_called()
mlflow_repository.emit_metric.assert_not_called()
@patch(JOBLIB_PATH)
@pytest.mark.asyncio
async def test_load_transform_model_rejects_pytorch(joblib_mock, mlflow, mlflow_repository):
with pytest.raises(ValueError) as exc_info:
await mlflow_repository.load_transform_model('test_model', {}, 'pytorch')
assert str(exc_info.value) == INVALID_FLAVOR_MESSAGE
mlflow.pytorch.load_model.assert_not_called()
joblib_mock.load.assert_not_called()
mlflow_repository.client.download_artifacts.assert_not_called()
mlflow_repository.emit_metric.assert_not_called()
@patch(JOBLIB_PATH)
@pytest.mark.asyncio
async def test_log_model_rejects_pytorch(joblib_mock, mlflow, mlflow_repository):
model_data = {'model': MagicMock(), 'artifact_path': 'artifact_path'}
with pytest.raises(ValueError) as exc_info:
await mlflow_repository.log_model(
model_data, 'pytorch', 'prediction_model', metadata['metadata']
)
assert str(exc_info.value) == INVALID_FLAVOR_MESSAGE
mlflow.pytorch.log_model.assert_not_called()
joblib_mock.dump.assert_not_called()
mlflow_repository.emit_metric.assert_called_once_with(
metric_object=metrics.MODEL_WRITE_ERROR_COUNT, tags=ANY
)
mlflow_repository.observe_lag.assert_not_called()
def test_invalid_flavor_message_lists_every_accepted_flavor(): def test_invalid_flavor_message_lists_every_accepted_flavor():
for flavor in ('sklearn', 'pyfunc', 'pytorch', 'joblib'): for flavor in ('sklearn', 'pyfunc', 'joblib'):
assert f"'{flavor}'" in INVALID_FLAVOR_MESSAGE assert f"'{flavor}'" in INVALID_FLAVOR_MESSAGE
assert 'pytorch' not in INVALID_FLAVOR_MESSAGE
@patch(JOBLIB_PATH) @patch(JOBLIB_PATH)
@pytest.mark.asyncio @pytest.mark.asyncio