Repository: mlflow/mlflow

Issue:
[BUG] mlflow autologging with mlflow skinny broken in recent versions
### Willingness to contribute

Yes. I would be willing to contribute a fix for this bug with guidance from the MLflow community.

### System information

- **Have I written custom code (as opposed to using a stock example script provided in MLflow)**: no
- **OS Platform and Distribution (e.g., Linux Ubuntu 16.04)**: linux ubuntu 
- **MLflow installed from (source or binary)**: pip
- **MLflow version (run ``mlflow --version``)**: 1.25.1
- **Python version**: 3.7.11
- **npm version, if running the dev UI**: n/a


### Describe the problem

When installing mlflow-skinny and launching `mlflow.autolog()`, an ImportError occurs (see below for trace).
Note that this worked in earlier versions of mlflow skinny, e.g. 1.20.0. 1.22.0 seems to be the last version where it's working.

### Tracking information

_No response_

### Code to reproduce issue

```bash
$ pip install mlflow-skinny
```
```python
import mlflow; mlflow.autolog()
```

### Other info / logs

```python-traceback
>>> import mlflow; mlflow.autolog()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/opt/conda/lib/python3.7/site-packages/mlflow/utils/autologging_utils/__init__.py", line 416, in autolog
    return _autolog(*args, **kwargs)
  File "/opt/conda/lib/python3.7/site-packages/mlflow/tracking/fluent.py", line 1574, in autolog
    from mlflow import (
  File "/opt/conda/lib/python3.7/site-packages/mlflow/tensorflow/__init__.py", line 26, in <module>
    import mlflow.keras
  File "/opt/conda/lib/python3.7/site-packages/mlflow/keras.py", line 21, in <module>
    from mlflow import pyfunc
  File "/opt/conda/lib/python3.7/site-packages/mlflow/pyfunc/__init__.py", line 228, in <module>
    from mlflow.models import Model, ModelSignature, ModelInputExample
ImportError: cannot import name 'ModelInputExample' from 'mlflow.models' (/opt/conda/lib/python3.7/site-packages/mlflow/models/__init__.py)
```

### What component(s) does this bug affect?

- [ ] `area/artifacts`: Artifact stores and artifact logging
- [ ] `area/build`: Build and test infrastructure for MLflow
- [ ] `area/docs`: MLflow documentation pages
- [ ] `area/examples`: Example code
- [ ] `area/model-registry`: Model Registry service, APIs, and the fluent client calls for Model Registry
- [ ] `area/models`: MLmodel format, model serialization/deserialization, flavors
- [ ] `area/projects`: MLproject format, project running backends
- [ ] `area/scoring`: MLflow Model server, model deployment tools, Spark UDFs
- [ ] `area/server-infra`: MLflow Tracking server backend
- [X] `area/tracking`: Tracking Service, tracking client APIs, autologging

### What interface(s) does this bug affect?

- [ ] `area/uiux`: Front-end, user experience, plotting, JavaScript, JavaScript dev server
- [ ] `area/docker`: Docker use across MLflow's components, such as MLflow Projects and MLflow Models
- [ ] `area/sqlalchemy`: Use of SQLAlchemy in the Tracking Service or Model Registry
- [ ] `area/windows`: Windows support

### What language(s) does this bug affect?

- [ ] `language/r`: R APIs and clients
- [ ] `language/java`: Java APIs and clients
- [ ] `language/new`: Proposals for new client languages

### What integration(s) does this bug affect?

- [ ] `integrations/azure`: Azure and Azure ML integrations
- [ ] `integrations/sagemaker`: SageMaker integrations
- [ ] `integrations/databricks`: Databricks integrations


Discussion:
@temporaer I think this line fails:

https://github.com/mlflow/mlflow/blob/c577c7f199d9efa076344785dabb2121edb7e6c8/mlflow/models/__init__.py#L44


Can you install `scipy` and run `mlflow.autolog` again?
Oh wow, it does work, thank you! I'm installing mlflow-skinny because i don't want heavy dependencies. Is scipy really always needed for autolog? Can we defer the import? Also even if that's not possible, could we print a more obvious error message?
> Is scipy really always needed for autolog?

Good question. No, it's needed only when you're using sparse matrices.

Resolving patch:
diff --git a/docs/source/conf.py b/docs/source/conf.py
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -339,6 +339,8 @@
     ("py:class", "mlflow.models.model.Model"),
     ("py:class", "mlflow.models.signature.ModelSignature"),
     ("py:class", "MlflowInferableDataset"),
+    ("py:class", "csr_matrix"),
+    ("py:class", "csc_matrix"),
     ("py:class", "scipy.sparse.csr.csr_matrix"),
     ("py:class", "scipy.sparse.csc.csc_matrix"),
     ("py:class", "scipy.sparse._csr.csr_matrix"),
diff --git a/mlflow/models/utils.py b/mlflow/models/utils.py
--- a/mlflow/models/utils.py
+++ b/mlflow/models/utils.py
@@ -9,9 +9,16 @@
 from mlflow.models import Model
 from mlflow.types.utils import TensorsNotSupportedException
 from mlflow.utils.proto_json_utils import NumpyEncoder, _dataframe_from_json, parse_tf_serving_input
-from scipy.sparse import csr_matrix, csc_matrix
 
-ModelInputExample = Union[pd.DataFrame, np.ndarray, dict, list, csr_matrix, csc_matrix]
+try:
+    from scipy.sparse import csr_matrix, csc_matrix
+
+    HAS_SCIPY = True
+except ImportError:
+    HAS_SCIPY = False
+
+
+ModelInputExample = Union[pd.DataFrame, np.ndarray, dict, list, "csr_matrix", "csc_matrix"]
 
 
 class _Example:
@@ -65,6 +72,10 @@ def _is_ndarray(x):
             )
 
         def _is_sparse_matrix(x):
+            if not HAS_SCIPY:
+                # we can safely assume that if no scipy is installed,
+                # the user won't log scipy sparse matrices
+                return False
             return isinstance(x, (csc_matrix, csr_matrix))
 
         def _handle_ndarray_nans(x: np.ndarray):
@@ -82,7 +93,7 @@ def _handle_ndarray_input(input_array: Union[np.ndarray, dict]):
             else:
                 return {"inputs": _handle_ndarray_nans(input_array).tolist()}
 
-        def _handle_sparse_matrix(x: Union[csr_matrix, csc_matrix]):
+        def _handle_sparse_matrix(x: Union["csr_matrix", "csc_matrix"]):
             return {
                 "data": _handle_ndarray_nans(x.data).tolist(),
                 "indices": x.indices.tolist(),
diff --git a/mlflow/pyfunc/__init__.py b/mlflow/pyfunc/__init__.py
--- a/mlflow/pyfunc/__init__.py
+++ b/mlflow/pyfunc/__init__.py
@@ -269,7 +269,13 @@
     INVALID_PARAMETER_VALUE,
     RESOURCE_DOES_NOT_EXIST,
 )
-from scipy.sparse import csc_matrix, csr_matrix
+
+try:
+    import scipy.sparse
+
+    HAS_SCIPY = True
+except ImportError:
+    HAS_SCIPY = False
 from mlflow.utils.requirements_utils import (
     _check_requirement_satisfied,
     _parse_requirements,
@@ -285,7 +291,14 @@
 PY_VERSION = "python_version"
 
 _logger = logging.getLogger(__name__)
-PyFuncInput = Union[pandas.DataFrame, np.ndarray, csc_matrix, csr_matrix, List[Any], Dict[str, Any]]
+PyFuncInput = Union[
+    pandas.DataFrame,
+    np.ndarray,
+    "scipy.sparse.csc_matrix",
+    "scipy.sparse.csr_matrix",
+    List[Any],
+    Dict[str, Any],
+]
 PyFuncOutput = Union[pandas.DataFrame, pandas.Series, np.ndarray, list]
 
 
@@ -430,7 +443,8 @@ def all_ints(xs):
 
 
 def _enforce_tensor_spec(
-    values: Union[np.ndarray, csc_matrix, csr_matrix], tensor_spec: TensorSpec
+    values: Union[np.ndarray, "scipy.sparse.csc_matrix", "scipy.sparse.csr_matrix"],
+    tensor_spec: TensorSpec,
 ):
     """
     Enforce the input tensor shape and type matches the provided tensor spec.
@@ -479,6 +493,13 @@ def _enforce_col_schema(pfInput: PyFuncInput, input_schema: Schema):
 
 def _enforce_tensor_schema(pfInput: PyFuncInput, input_schema: Schema):
     """Enforce the input tensor(s) conforms to the model's tensor-based signature."""
+
+    def _is_sparse_matrix(x):
+        if not HAS_SCIPY:
+            # we can safely assume that it's not a sparse matrix if scipy is not installed
+            return False
+        return isinstance(x, (scipy.sparse.csr_matrix, scipy.sparse.csc_matrix))
+
     if input_schema.has_input_names():
         if isinstance(pfInput, dict):
             new_pfInput = dict()
@@ -507,7 +528,7 @@ def _enforce_tensor_schema(pfInput: PyFuncInput, input_schema: Schema):
     else:
         if isinstance(pfInput, pandas.DataFrame):
             new_pfInput = _enforce_tensor_spec(pfInput.to_numpy(), input_schema.inputs[0])
-        elif isinstance(pfInput, (np.ndarray, csc_matrix, csr_matrix)):
+        elif isinstance(pfInput, np.ndarray) or _is_sparse_matrix(pfInput):
             new_pfInput = _enforce_tensor_spec(pfInput, input_schema.inputs[0])
         else:
             raise MlflowException(


Test patch:
diff --git a/tests/test_skinny_client_autolog_without_scipy.py b/tests/test_skinny_client_autolog_without_scipy.py
new file mode 100644
--- /dev/null
+++ b/tests/test_skinny_client_autolog_without_scipy.py
@@ -0,0 +1,17 @@
+import pytest
+import os
+
+
+@pytest.mark.skipif(
+    "MLFLOW_SKINNY" not in os.environ, reason="This test is only valid for the skinny client"
+)
+def test_autolog_without_scipy():
+    import mlflow
+
+    with pytest.raises(ImportError, match="scipy"):
+        import scipy  # pylint: disable=unused-import
+
+    assert not mlflow.models.utils.HAS_SCIPY
+
+    mlflow.autolog()
+    mlflow.models.utils._Example(dict())