Repository: mlflow/mlflow
Pull request: Sklearn autologging: Fix behavior when a child and its parent are both patched
## What changes are proposed in this pull request?

Fixes https://github.com/mlflow/mlflow/issues/3574 by addressing the following two issues:

1. In gorilla 0.3.0, applying a patch that stores a hit to a parent class (e.g., `CountVectorizer`) forces `get_original_attribute` of its children to refer to the parent class attribute, even if we patch the child later on. I.e.

This ordering is okay
```
gorilla.Patch(<TfidfVectorizer>)
gorilla.Patch(<CountVectorizer>)
```

but this ordering is not

```
gorilla.Patch(<CountVectorizer>)
gorilla.Patch(<TfidfVectorizer>)
```

This appears to be caused by the following line that prevents the original attribute from being overwritten once it's set: https://github.com/christophercrouzet/gorilla/blob/0c895b34311b6546bde35854e24862c001279add/gorilla.py#L280. As a result, setting the attribute on the parent, which sets the original attribute on the child if the child does not already define it, means that the child is always stuck with the parent's original attribute. This seems to be fixed in the `master` branch of gorilla but has not been released. Either we can pull in the copy from `master` or pull in a copy from 0.3.0 that removes this problematic `hasattr` check.

2. In `fit_mlflow` and `fit_predict`, we perform `get_original_attribute(self, func_name)`. In the case where we're in a super class function (e.g., `CountVectorizer.fit_transform()`), `self` still refers to the subclass (e.g., `TfidfVectorizer`). As a result, calling `get_original_attribute` in the patched super class method fetches invokes the patched subclass method, which creates the infinite recursion problem observed in the issue description. I've filed https://github.com/mlflow/mlflow/pull/3582/ to address this problem by fetching the original function attribute by reference to a class rather than by reference to `self`. This PR still needs docs & tests, and it needs to incorporate a fix for (1) before it's mergeable.

## How is this patch tested?

Unit tests

## Release Notes

Fixes an infinite recursion bug in scikit-learn autologging caused by invoking a `fit()` method on a model class that includes a call to `super.fit()` as part of its implementation (see https://github.com/mlflow/mlflow/issues/3574).

### Is this a user-facing change?

- [ ] No. You can skip the rest of this section.
- [X] Yes. Give a description of this change to be included in the release notes for MLflow users.

### What component(s), interfaces, languages, and integrations does this PR affect?
Components 
- [ ] `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`: Local serving, model deployment tools, spark UDFs
- [ ] `area/server-infra`: MLflow server, JavaScript dev server
- [X] `area/tracking`: Tracking Service, tracking client APIs, autologging

Interface 
- [ ] `area/uiux`: Front-end, user experience, JavaScript, plotting
- [ ] `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

Language 
- [ ] `language/r`: R APIs and clients
- [ ] `language/java`: Java APIs and clients
- [ ] `language/new`: Proposals for new client languages

Integrations
- [ ] `integrations/azure`: Azure and Azure ML integrations
- [ ] `integrations/sagemaker`: SageMaker integrations
- [ ] `integrations/databricks`: Databricks integrations

### How should the PR be classified in the release notes? Choose one:

- [ ] `rn/breaking-change` - The PR will be mentioned in the "Breaking Changes" section
- [ ] `rn/none` - No description will be included. The PR will be mentioned only by the PR number in the "Small Bugfixes and Documentation Updates" section
- [ ] `rn/feature` - A new user-facing feature worth mentioning in the release notes
- [X] `rn/bug-fix` - A user-facing bug fix worth mentioning in the release notes
- [ ] `rn/documentation` - A user-facing documentation change worth mentioning in the release notes


Problem statement:
sklearn autolog and TfidfVectorizer do not work together
When running this minimal example with mlflow autolog:

```
from sklearn.feature_extraction.text import TfidfVectorizer
import mlflow

mlflow.sklearn.autolog()

X_train = ['blah blah blah blah', 'ho ho ho ho']
tfidf_count = TfidfVectorizer()
tfidf_count.fit(X_train)
tfidf_count.transform(X_train).toarray()
```

I get a strange error that I cannot understand about `RecursionError: maximum recursion depth exceeded`. However if I remove the autolog statement and run this:

```
from sklearn.feature_extraction.text import TfidfVectorizer
import mlflow

# mlflow.sklearn.autolog()

X_train = ['blah blah blah blah', 'ho ho ho ho']
tfidf_count = TfidfVectorizer()
tfidf_count.fit(X_train)
tfidf_count.transform(X_train).toarray()
```

everything works just fine.

I am on scikit-learn==0.23.2, mlflow==1.11.0.

Hints from issue discussion:
@sheldonkhall Thanks for reporting this. I was able to reproduce the issue.
@harupy Thanks for jumping on it!
Wrote a script to repro & investigate the issue:

```python
import functools
import gorilla
from mlflow.utils.autologging_utils import wrap_patch

# context
# 1. `TfidfVectorizer` inherits from `CountVectorizer`.
# 2. `TfidfVectorizer.fit_transform` internally calls `super().fit_transform`.

class CountVectorizer:
    def fit_transform(self):
        print("CountVectorizer.fit_transform called")


class TfidfVectorizer(CountVectorizer):
    def fit_transform(self):
        print("TfidfVectorizer.fit_transform called")
        super().fit_transform()


FUNC_NAME = "fit_transform"


def fit_transform1(self, *args, **kwargs):
    print("fit_transform1 called")
    original_fit1 = gorilla.get_original_attribute(self, FUNC_NAME)
    return original_fit1(*args, **kwargs)


def fit_transform2(self, *args, **kwargs):
    print("fit_transform2 called")
    original_fit2 = gorilla.get_original_attribute(self, FUNC_NAME)
    return original_fit2(*args, **kwargs)


wrap_patch(TfidfVectorizer, FUNC_NAME, fit_transform1)
wrap_patch(CountVectorizer, FUNC_NAME, fit_transform2)


tfidf = TfidfVectorizer()
tfidf.fit_transform()

# output:
"""
fit_transform1 called
TfidfVectorizer.fit_transform called
fit_transform2 called
TfidfVectorizer.fit_transform called
fit_transform2 called
TfidfVectorizer.fit_transform called
...

RecursionError: maximum recursion depth exceeded while calling a Python object
"""


# How this error occurs:
"""
1. `TfidfVectorizer.fit_transform` called
2. `fit_transform1` called
3. `original_fit1` (the original `TfidfVectorizer.fit_transform`) called
4. `super().fit_transform` (= `CountVectorizer.fit_transform`) called
5. `fit_transform2` is called
6. `original_fit2` (the original `TfidfVectorizer.fit_transform`) called
7. Goes back to step 4 (== infinite loop)
"""

```

cc @dbczumar @andrewnitu 
Hi folks, I dug into this and found the root cause + a potential fix. There were two issues - one in gorilla itself and one with our usage of gorilla: 

1. In gorilla 0.3.0, applying a patch that stores a hit to a parent class (e.g., `CountVectorizer`) forces `get_original_attribute` of its children to refer to the parent class attribute, even if we patch the child later on. I.e.

This ordering is okay
```
gorilla.Patch(<TfidfVectorizer>)
gorilla.Patch(<CountVectorizer>)
```

but this ordering is not

```
gorilla.Patch(<CountVectorizer>)
gorilla.Patch(<TfidfVectorizer>)
```

This appears to be caused by the following line that prevents the original attribute from being overwritten once it's set: https://github.com/christophercrouzet/gorilla/blob/0c895b34311b6546bde35854e24862c001279add/gorilla.py#L280. As a result, setting the attribute on the parent, which sets the original attribute on the child if the child does not already define it, means that the child is always stuck with the parent's original attribute. This seems to be fixed in the `master` branch of gorilla but has not been released. Either we can pull in the copy from `master` or pull in a copy from 0.3.0 that removes this problematic `hasattr` check.

2. In `fit_mlflow` and `fit_predict`, we perform `get_original_attribute(self, func_name)`. In the case where we're in a super class function (e.g., `CountVectorizer.fit_transform()`), `self` still refers to the subclass (e.g., `TfidfVectorizer`). As a result, calling `get_original_attribute` in the patched super class method fetches invokes the patched subclass method, which creates the infinite recursion problem observed in the issue description. I've filed https://github.com/mlflow/mlflow/pull/3582/ to address this problem by fetching the original function attribute by reference to a class rather than by reference to `self`. This PR still needs docs & tests, and it needs to incorporate a fix for (1) before it's mergeable.

@harupy Let me know what your thoughts are here.

Commit message: Black format

Signed-off-by: Corey Zumar <corey.zumar@databricks.com>

Patch to review:
diff --git a/mlflow/_spark_autologging.py b/mlflow/_spark_autologging.py
index 31a2a091c4172..1ddba8643695a 100644
--- a/mlflow/_spark_autologging.py
+++ b/mlflow/_spark_autologging.py
@@ -3,7 +3,6 @@
 import sys
 import threading
 import uuid
-import gorilla
 
 from py4j.java_gateway import CallbackServerParameters
 
@@ -15,6 +14,7 @@
 from mlflow.exceptions import MlflowException
 from mlflow.tracking.client import MlflowClient
 from mlflow.tracking.context.abstract_context import RunContextProvider
+from mlflow.utils import gorilla
 from mlflow.utils.autologging_utils import wrap_patch
 
 _JAVA_PACKAGE = "org.mlflow.spark.autologging"
diff --git a/mlflow/fastai.py b/mlflow/fastai.py
index e62343c2c80af..e65bf75529695 100644
--- a/mlflow/fastai.py
+++ b/mlflow/fastai.py
@@ -14,7 +14,6 @@
 """
 import os
 import yaml
-import gorilla
 import tempfile
 import shutil
 import pandas as pd
@@ -27,6 +26,7 @@
 from mlflow.models.utils import _save_example
 from mlflow.models.model import MLMODEL_FILE_NAME
 from mlflow.tracking.artifact_utils import _download_artifact_from_uri
+from mlflow.utils import gorilla
 from mlflow.utils.environment import _mlflow_conda_env
 from mlflow.utils.model_utils import _get_flavor_configuration
 from mlflow.utils.annotations import experimental
diff --git a/mlflow/gluon.py b/mlflow/gluon.py
index ed913a44ebc2d..a1bd306ab3f34 100644
--- a/mlflow/gluon.py
+++ b/mlflow/gluon.py
@@ -1,6 +1,5 @@
 import os
 
-import gorilla
 import pandas as pd
 import yaml
 
@@ -12,6 +11,7 @@
 from mlflow.models.signature import ModelSignature
 from mlflow.models.utils import ModelInputExample, _save_example
 from mlflow.tracking.artifact_utils import _download_artifact_from_uri
+from mlflow.utils import gorilla
 from mlflow.utils.annotations import experimental
 from mlflow.utils.autologging_utils import try_mlflow_log, wrap_patch
 from mlflow.utils.environment import _mlflow_conda_env
diff --git a/mlflow/keras.py b/mlflow/keras.py
index 1cd2e12c62e8f..351615b815e02 100644
--- a/mlflow/keras.py
+++ b/mlflow/keras.py
@@ -10,7 +10,6 @@
 import importlib
 import os
 import yaml
-import gorilla
 import tempfile
 import shutil
 
@@ -25,6 +24,7 @@
 from mlflow.models.signature import ModelSignature
 from mlflow.models.utils import ModelInputExample, _save_example
 from mlflow.tracking.artifact_utils import _download_artifact_from_uri
+from mlflow.utils import gorilla
 from mlflow.utils.environment import _mlflow_conda_env
 from mlflow.utils.model_utils import _get_flavor_configuration
 from mlflow.utils.annotations import experimental
diff --git a/mlflow/lightgbm.py b/mlflow/lightgbm.py
index 812b5f8ac4953..53e0308dd8eeb 100644
--- a/mlflow/lightgbm.py
+++ b/mlflow/lightgbm.py
@@ -24,7 +24,6 @@
 import shutil
 import inspect
 import logging
-import gorilla
 from copy import deepcopy
 
 import mlflow
@@ -34,6 +33,7 @@
 from mlflow.models.signature import ModelSignature
 from mlflow.models.utils import ModelInputExample, _save_example
 from mlflow.tracking.artifact_utils import _download_artifact_from_uri
+from mlflow.utils import gorilla
 from mlflow.utils.environment import _mlflow_conda_env
 from mlflow.utils.model_utils import _get_flavor_configuration
 from mlflow.exceptions import MlflowException
diff --git a/mlflow/sklearn/__init__.py b/mlflow/sklearn/__init__.py
index d800ded5032fb..22ad94d7a92c1 100644
--- a/mlflow/sklearn/__init__.py
+++ b/mlflow/sklearn/__init__.py
@@ -8,7 +8,6 @@
 :py:mod:`mlflow.pyfunc`
     Produced for use by generic pyfunc-based deployment tools and batch inference.
 """
-import gorilla
 import os
 import logging
 import pickle
@@ -26,6 +25,7 @@
 from mlflow.protos.databricks_pb2 import INVALID_PARAMETER_VALUE, INTERNAL_ERROR
 from mlflow.protos.databricks_pb2 import RESOURCE_ALREADY_EXISTS
 from mlflow.tracking.artifact_utils import _download_artifact_from_uri
+from mlflow.utils import gorilla
 from mlflow.utils.annotations import experimental
 from mlflow.utils.environment import _mlflow_conda_env
 from mlflow.utils.model_utils import _get_flavor_configuration
@@ -724,16 +724,21 @@ def fetch_logged_data(run_id):
             stacklevel=2,
         )
 
-    def fit_mlflow(self, func_name, *args, **kwargs):
+    def fit_mlflow(self, clazz, func_name, *args, **kwargs):
+        """
+        Autologging function that performs model training by executing the training method
+        referred to be `func_name` on the instance of `clazz` referred to by `self` & records
+        MLflow parameters, metrics, tags, and artifacts to a corresponding MLflow Run.
+        """
         should_start_run = mlflow.active_run() is None
         if should_start_run:
             try_mlflow_log(mlflow.start_run)
 
         _log_pretraining_metadata(self, *args, **kwargs)
 
-        original_fit = gorilla.get_original_attribute(self, func_name)
+        original_fit = gorilla.get_original_attribute(clazz, func_name)
         try:
-            fit_output = original_fit(*args, **kwargs)
+            fit_output = original_fit(self, *args, **kwargs)
         except Exception as e:
             if should_start_run:
                 try_mlflow_log(mlflow.end_run, RunStatus.to_string(RunStatus.FAILED))
@@ -891,21 +896,27 @@ def infer_model_signature(input_example):
                     )
                     _logger.warning(msg)
 
-    def patched_fit(self, func_name, *args, **kwargs):
+    def patched_fit(self, clazz, func_name, *args, **kwargs):
         """
-        To be applied to a sklearn model class that defines a `fit` method and
-        inherits from `BaseEstimator` (thereby defining the `get_params()` method)
+        Autologging patch function to be applied to a sklearn model class that defines a `fit`
+        method and inherits from `BaseEstimator` (thereby defining the `get_params()` method)
+
+        :param clazz: The scikit-learn model class to which this patch function is being applied for
+                      autologging (e.g., `sklearn.linear_model.LogisticRegression`)
+        :param func_name: The function name on the specified `clazz` that this patch is overriding
+                          for autologging (e.g., specify "fit" in order to indicate that
+                          `sklearn.linear_model.LogisticRegression.fit()` is being patched)
         """
-        with _SklearnTrainingSession(clazz=self.__class__, allow_children=False) as t:
+        with _SklearnTrainingSession(clazz=clazz, allow_children=False) as t:
             if t.should_log():
-                return fit_mlflow(self, func_name, *args, **kwargs)
+                return fit_mlflow(self, clazz, func_name, *args, **kwargs)
             else:
-                original_fit = gorilla.get_original_attribute(self, func_name)
-                return original_fit(*args, **kwargs)
+                original_fit = gorilla.get_original_attribute(clazz, func_name)
+                return original_fit(self, *args, **kwargs)
 
-    def create_patch_func(func_name):
+    def create_patch_func(clazz, func_name):
         def f(self, *args, **kwargs):
-            return patched_fit(self, func_name, *args, **kwargs)
+            return patched_fit(self, clazz, func_name, *args, **kwargs)
 
         return f
 
@@ -965,5 +976,5 @@ def f(self, *args, **kwargs):
                 if isinstance(original, property):
                     continue
 
-                patch_func = create_patch_func(func_name)
+                patch_func = create_patch_func(class_def, func_name)
                 wrap_patch(class_def, func_name, patch_func)
diff --git a/mlflow/tensorflow.py b/mlflow/tensorflow.py
index 152a5603ddab6..955d475a6ac2e 100644
--- a/mlflow/tensorflow.py
+++ b/mlflow/tensorflow.py
@@ -11,7 +11,6 @@
 import shutil
 import yaml
 import logging
-import gorilla
 import concurrent.futures
 import warnings
 import atexit
@@ -32,6 +31,7 @@
 from mlflow.models.utils import ModelInputExample, _save_example
 from mlflow.protos.databricks_pb2 import DIRECTORY_NOT_EMPTY
 from mlflow.tracking.artifact_utils import _download_artifact_from_uri
+from mlflow.utils import gorilla
 from mlflow.utils.annotations import keyword_only, experimental
 from mlflow.utils.environment import _mlflow_conda_env
 from mlflow.utils.file_utils import _copy_file_or_tree
diff --git a/mlflow/utils/autologging_utils.py b/mlflow/utils/autologging_utils.py
index adc27befcb041..dc5ed301636c7 100644
--- a/mlflow/utils/autologging_utils.py
+++ b/mlflow/utils/autologging_utils.py
@@ -1,9 +1,9 @@
 import inspect
 import functools
-import gorilla
 import warnings
 
 import mlflow
+from mlflow.utils import gorilla
 
 
 INPUT_EXAMPLE_SAMPLE_ROWS = 5
diff --git a/mlflow/utils/gorilla.py b/mlflow/utils/gorilla.py
new file mode 100644
index 0000000000000..115a8ea854d5f
--- /dev/null
+++ b/mlflow/utils/gorilla.py
@@ -0,0 +1,898 @@
+#                     __ __ __
+#   .-----.-----.----|__|  |  .---.-.
+#   |  _  |  _  |   _|  |  |  |  _  |
+#   |___  |_____|__| |__|__|__|___._|
+#   |_____|
+#
+
+"""
+NOTE: The contents of this file have been inlined from the gorilla package's source code
+https://github.com/christophercrouzet/gorilla/blob/v0.3.0/gorilla.py
+The following modifications have been made:
+    - When a patch is applied to a method `foo` on parent class `A` and a subsequent
+      patch is applied to `foo` on one of its children `B`, `get_original_attribute(B, "foo")`
+      will now refer to `B.foo`, rather than `A.foo`.
+
+gorilla
+~~~~~~~
+
+Convenient approach to monkey patching.
+
+:copyright: Copyright 2014-2017 by Christopher Crouzet.
+:license: MIT, see LICENSE for details.
+"""
+
+import collections
+import copy
+import inspect
+import pkgutil
+import sys
+import types
+
+
+__version__ = "0.3.0"
+
+
+if sys.version_info[0] == 2:
+    _CLASS_TYPES = (type, types.ClassType)
+
+    def _iteritems(d, **kwargs):
+        return d.iteritems(**kwargs)
+
+    def _load_module(finder, name):
+        loader = finder.find_module(name)
+        return loader.load_module(name)
+
+
+else:
+    _CLASS_TYPES = (type,)
+
+    def _iteritems(d, **kwargs):
+        return iter(d.items(**kwargs))
+
+    def _load_module(finder, name):
+        loader, _ = finder.find_loader(name)
+        return loader.load_module()
+
+
+# Pattern for each internal attribute name.
+_PATTERN = "_gorilla_%s"
+
+# Pattern for the name of the overidden attributes to be stored.
+_ORIGINAL_NAME = _PATTERN % ("original_%s",)
+
+_ACTIVE_PATCH = "_gorilla_active_patch"
+
+# Attribute for the decorator data.
+_DECORATOR_DATA = _PATTERN % ("decorator_data",)
+
+
+def default_filter(name, obj):
+    """Attribute filter.
+
+    It filters out module attributes, and also methods starting with an
+    underscore ``_``.
+
+    This is used as the default filter for the :func:`create_patches` function
+    and the :func:`patches` decorator.
+
+    Parameters
+    ----------
+    name : str
+        Attribute name.
+    obj : object
+        Attribute value.
+
+    Returns
+    -------
+    bool
+        Whether the attribute should be returned.
+    """
+    return not (isinstance(obj, types.ModuleType) or name.startswith("_"))
+
+
+class DecoratorData(object):
+
+    """Decorator data.
+
+    Attributes
+    ----------
+    patches : list of gorilla.Patch
+        Patches created through the decorators.
+    override : dict
+        Any overriding value defined by the :func:`destination`, :func:`name`,
+        and :func:`settings` decorators.
+    filter : bool or None
+        Value defined by the :func:`filter` decorator, if any, or ``None``
+        otherwise.
+    """
+
+    def __init__(self):
+        """Constructor."""
+        self.patches = []
+        self.override = {}
+        self.filter = None
+
+
+class Settings(object):
+
+    """Define the patching behaviour.
+
+    Attributes
+    ----------
+    allow_hit : bool
+        A hit occurs when an attribute at the destination already exists with
+        the name given by the patch. If ``False``, the patch process won't
+        allow setting a new value for the attribute by raising an exception.
+        Defaults to ``False``.
+    store_hit : bool
+        If ``True`` and :attr:`allow_hit` is also set to ``True``, then any
+        attribute at the destination that is hit is stored under a different
+        name before being overwritten by the patch. Defaults to ``True``.
+    """
+
+    def __init__(self, **kwargs):
+        """Constructor.
+
+        Parameters
+        ----------
+        kwargs
+            Keyword arguments, see the attributes.
+        """
+        self.allow_hit = False
+        self.store_hit = True
+        self._update(**kwargs)
+
+    def __repr__(self):
+        values = ", ".join(
+            ["%s=%r" % (key, value) for key, value in sorted(_iteritems(self.__dict__))]
+        )
+        return "%s(%s)" % (type(self).__name__, values)
+
+    def __eq__(self, other):
+        if isinstance(other, type(self)):
+            return self.__dict__ == other.__dict__
+
+        return NotImplemented
+
+    def __ne__(self, other):
+        is_equal = self.__eq__(other)
+        return is_equal if is_equal is NotImplemented else not is_equal
+
+    def _update(self, **kwargs):
+        """Update some settings.
+
+        Parameters
+        ----------
+        kwargs
+            Settings to update.
+        """
+        self.__dict__.update(**kwargs)
+
+
+class Patch(object):
+
+    """Describe all the information required to apply a patch.
+
+    Attributes
+    ----------
+    destination : obj
+        Patch destination.
+    name : str
+        Name of the attribute at the destination.
+    obj : obj
+        Attribute value.
+    settings : gorilla.Settings or None
+        Settings. If ``None``, the default settings are used.
+
+    Warning
+    -------
+    It is highly recommended to use the output of the function
+    :func:`get_attribute` for setting the attribute :attr:`obj`. This will
+    ensure that the descriptor protocol is bypassed instead of possibly
+    retrieving attributes invalid for patching, such as bound methods.
+    """
+
+    def __init__(self, destination, name, obj, settings=None):
+        """Constructor.
+
+        Parameters
+        ----------
+        destination : object
+            See the :attr:`~Patch.destination` attribute.
+        name : str
+            See the :attr:`~Patch.name` attribute.
+        obj : object
+            See the :attr:`~Patch.obj` attribute.
+        settings : gorilla.Settings
+            See the :attr:`~Patch.settings` attribute.
+        """
+        self.destination = destination
+        self.name = name
+        self.obj = obj
+        self.settings = settings
+
+    def __repr__(self):
+        return "%s(destination=%r, name=%r, obj=%r, settings=%r)" % (
+            type(self).__name__,
+            self.destination,
+            self.name,
+            self.obj,
+            self.settings,
+        )
+
+    def __eq__(self, other):
+        if isinstance(other, type(self)):
+            return self.__dict__ == other.__dict__
+
+        return NotImplemented
+
+    def __ne__(self, other):
+        is_equal = self.__eq__(other)
+        return is_equal if is_equal is NotImplemented else not is_equal
+
+    def _update(self, **kwargs):
+        """Update some attributes.
+
+        If a 'settings' attribute is passed as a dict, then it will update the
+        content of the settings, if any, instead of completely overwriting it.
+
+        Parameters
+        ----------
+        kwargs
+            Attributes to update.
+
+        Raises
+        ------
+        ValueError
+            The setting doesn't exist.
+        """
+        for key, value in _iteritems(kwargs):
+            if key == "settings":
+                if isinstance(value, dict):
+                    if self.settings is None:
+                        self.settings = Settings(**value)
+                    else:
+                        self.settings._update(**value)
+                else:
+                    self.settings = copy.deepcopy(value)
+            else:
+                setattr(self, key, value)
+
+
+def apply(patch):
+    """Apply a patch.
+
+    The patch's :attr:`~Patch.obj` attribute is injected into the patch's
+    :attr:`~Patch.destination` under the patch's :attr:`~Patch.name`.
+
+    This is a wrapper around calling
+    ``setattr(patch.destination, patch.name, patch.obj)``.
+
+    Parameters
+    ----------
+    patch : gorilla.Patch
+        Patch.
+
+    Raises
+    ------
+    RuntimeError
+        Overwriting an existing attribute is not allowed when the setting
+        :attr:`Settings.allow_hit` is set to ``True``.
+
+    Note
+    ----
+    If both the attributes :attr:`Settings.allow_hit` and
+    :attr:`Settings.store_hit` are ``True`` but that the target attribute seems
+    to have already been stored, then it won't be stored again to avoid losing
+    the original attribute that was stored the first time around.
+    """
+    settings = Settings() if patch.settings is None else patch.settings
+
+    # When a hit occurs due to an attribute at the destination already existing
+    # with the patch's name, the existing attribute is referred to as 'target'.
+    try:
+        target = get_attribute(patch.destination, patch.name)
+    except AttributeError:
+        pass
+    else:
+        if not settings.allow_hit:
+            raise RuntimeError(
+                "An attribute named '%s' already exists at the destination "
+                "'%s'. Set a different name through the patch object to avoid "
+                "a name clash or set the setting 'allow_hit' to True to "
+                "overwrite the attribute. In the latter case, it is "
+                "recommended to also set the 'store_hit' setting to True in "
+                "order to store the original attribute under a different "
+                "name so it can still be accessed." % (patch.name, patch.destination.__name__)
+            )
+
+        if settings.store_hit:
+            original_name = _ORIGINAL_NAME % (patch.name,)
+            # For certain MLflow Models use cases, such as scikit-learn autologging, we patch
+            # a method on a parent class
+            # (e.g., `sklearn.feature_extraction.text.CountVectorizer.fit_transform()`) and
+            # subsequently patch a corresponding overridden method on one of its children
+            # (e.g., `feature_extraction.text.TfidfVectorizer.fit_transform()`)
+            # to provide a different implementation. In these cases, we wish to swap out the
+            # "original function" attribute stored on the child in order to refer to the child's
+            # overriden method (e.g., `feature_extraction.text.TfidfVectorizer.fit_transform()`),
+            # rather than the parent method
+            # (e.g., `sklearn.feature_extraction.text.CountVectorizer.fit_transform()`)
+            prev_patch = getattr(patch.destination, _ACTIVE_PATCH, None)
+            if not hasattr(patch.destination, original_name) or (
+                prev_patch
+                and prev_patch.destination != patch.destination
+                and issubclass(patch.destination, prev_patch.destination)
+            ):
+                setattr(patch.destination, original_name, target)
+
+    setattr(patch.destination, patch.name, patch.obj)
+    setattr(patch.destination, _ACTIVE_PATCH, patch)
+
+
+def patch(destination, name=None, settings=None):
+    """Decorator to create a patch.
+
+    The object being decorated becomes the :attr:`~Patch.obj` attribute of the
+    patch.
+
+    Parameters
+    ----------
+    destination : object
+        Patch destination.
+    name : str
+        Name of the attribute at the destination.
+    settings : gorilla.Settings
+        Settings.
+
+    Returns
+    -------
+    object
+        The decorated object.
+
+    See Also
+    --------
+    :class:`Patch`.
+    """
+
+    def decorator(wrapped):
+        base = _get_base(wrapped)
+        name_ = base.__name__ if name is None else name
+        settings_ = copy.deepcopy(settings)
+        patch = Patch(destination, name_, wrapped, settings=settings_)
+        data = get_decorator_data(base, set_default=True)
+        data.patches.append(patch)
+        return wrapped
+
+    return decorator
+
+
+def patches(
+    destination,
+    settings=None,
+    traverse_bases=True,
+    filter=default_filter,
+    recursive=True,
+    use_decorators=True,
+):
+    """Decorator to create a patch for each member of a module or a class.
+
+    Parameters
+    ----------
+    destination : object
+        Patch destination.
+    settings : gorilla.Settings
+        Settings.
+    traverse_bases : bool
+        If the object is a class, the base classes are also traversed.
+    filter : function
+        Attributes for which the function returns ``False`` are skipped. The
+        function needs to define two parameters: ``name``, the attribute name,
+        and ``obj``, the attribute value. If ``None``, no attribute is skipped.
+    recursive : bool
+        If ``True``, and a hit occurs due to an attribute at the destination
+        already existing with the given name, and both the member and the
+        target attributes are classes, then instead of creating a patch
+        directly with the member attribute value as is, a patch for each of its
+        own members is created with the target as new destination.
+    use_decorators : bool
+        Allows to take any modifier decorator into consideration to allow for
+        more granular customizations.
+
+    Returns
+    -------
+    object
+        The decorated object.
+
+    Note
+    ----
+    A 'target' differs from a 'destination' in that a target represents an
+    existing attribute at the destination about to be hit by a patch.
+
+    See Also
+    --------
+    :class:`Patch`, :func:`create_patches`.
+    """
+
+    def decorator(wrapped):
+        settings_ = copy.deepcopy(settings)
+        patches = create_patches(
+            destination,
+            wrapped,
+            settings=settings_,
+            traverse_bases=traverse_bases,
+            filter=filter,
+            recursive=recursive,
+            use_decorators=use_decorators,
+        )
+        data = get_decorator_data(_get_base(wrapped), set_default=True)
+        data.patches.extend(patches)
+        return wrapped
+
+    return decorator
+
+
+def destination(value):
+    """Modifier decorator to update a patch's destination.
+
+    This only modifies the behaviour of the :func:`create_patches` function
+    and the :func:`patches` decorator, given that their parameter
+    ``use_decorators`` is set to ``True``.
+
+    Parameters
+    ----------
+    value : object
+        Patch destination.
+
+    Returns
+    -------
+    object
+        The decorated object.
+    """
+
+    def decorator(wrapped):
+        data = get_decorator_data(_get_base(wrapped), set_default=True)
+        data.override["destination"] = value
+        return wrapped
+
+    return decorator
+
+
+def name(value):
+    """Modifier decorator to update a patch's name.
+
+    This only modifies the behaviour of the :func:`create_patches` function
+    and the :func:`patches` decorator, given that their parameter
+    ``use_decorators`` is set to ``True``.
+
+    Parameters
+    ----------
+    value : object
+        Patch name.
+
+    Returns
+    -------
+    object
+        The decorated object.
+    """
+
+    def decorator(wrapped):
+        data = get_decorator_data(_get_base(wrapped), set_default=True)
+        data.override["name"] = value
+        return wrapped
+
+    return decorator
+
+
+def settings(**kwargs):
+    """Modifier decorator to update a patch's settings.
+
+    This only modifies the behaviour of the :func:`create_patches` function
+    and the :func:`patches` decorator, given that their parameter
+    ``use_decorators`` is set to ``True``.
+
+    Parameters
+    ----------
+    kwargs
+        Settings to update. See :class:`Settings` for the list.
+
+    Returns
+    -------
+    object
+        The decorated object.
+    """
+
+    def decorator(wrapped):
+        data = get_decorator_data(_get_base(wrapped), set_default=True)
+        data.override.setdefault("settings", {}).update(kwargs)
+        return wrapped
+
+    return decorator
+
+
+def filter(value):
+    """Modifier decorator to force the inclusion or exclusion of an attribute.
+
+    This only modifies the behaviour of the :func:`create_patches` function
+    and the :func:`patches` decorator, given that their parameter
+    ``use_decorators`` is set to ``True``.
+
+    Parameters
+    ----------
+    value : bool
+        ``True`` to force inclusion, ``False`` to force exclusion, and ``None``
+        to inherit from the behaviour defined by :func:`create_patches` or
+        :func:`patches`.
+
+    Returns
+    -------
+    object
+        The decorated object.
+    """
+
+    def decorator(wrapped):
+        data = get_decorator_data(_get_base(wrapped), set_default=True)
+        data.filter = value
+        return wrapped
+
+    return decorator
+
+
+def create_patches(
+    destination,
+    root,
+    settings=None,
+    traverse_bases=True,
+    filter=default_filter,
+    recursive=True,
+    use_decorators=True,
+):
+    """Create a patch for each member of a module or a class.
+
+    Parameters
+    ----------
+    destination : object
+        Patch destination.
+    root : object
+        Root object, either a module or a class.
+    settings : gorilla.Settings
+        Settings.
+    traverse_bases : bool
+        If the object is a class, the base classes are also traversed.
+    filter : function
+        Attributes for which the function returns ``False`` are skipped. The
+        function needs to define two parameters: ``name``, the attribute name,
+        and ``obj``, the attribute value. If ``None``, no attribute is skipped.
+    recursive : bool
+        If ``True``, and a hit occurs due to an attribute at the destination
+        already existing with the given name, and both the member and the
+        target attributes are classes, then instead of creating a patch
+        directly with the member attribute value as is, a patch for each of its
+        own members is created with the target as new destination.
+    use_decorators : bool
+        ``True`` to take any modifier decorator into consideration to allow for
+        more granular customizations.
+
+    Returns
+    -------
+    list of gorilla.Patch
+        The patches.
+
+    Note
+    ----
+    A 'target' differs from a 'destination' in that a target represents an
+    existing attribute at the destination about to be hit by a patch.
+
+    See Also
+    --------
+    :func:`patches`.
+    """
+    if filter is None:
+        filter = _true
+
+    out = []
+    root_patch = Patch(destination, "", root, settings=settings)
+    stack = collections.deque((root_patch,))
+    while stack:
+        parent_patch = stack.popleft()
+        members = _get_members(
+            parent_patch.obj, traverse_bases=traverse_bases, filter=None, recursive=False
+        )
+        for name, value in members:
+            patch = Patch(
+                parent_patch.destination, name, value, settings=copy.deepcopy(parent_patch.settings)
+            )
+            if use_decorators:
+                base = _get_base(value)
+                decorator_data = get_decorator_data(base)
+                filter_override = None if decorator_data is None else decorator_data.filter
+                if (
+                    filter_override is None and not filter(name, value)
+                ) or filter_override is False:
+                    continue
+
+                if decorator_data is not None:
+                    patch._update(**decorator_data.override)
+            elif not filter(name, value):
+                continue
+
+            if recursive and isinstance(value, _CLASS_TYPES):
+                try:
+                    target = get_attribute(patch.destination, patch.name)
+                except AttributeError:
+                    pass
+                else:
+                    if isinstance(target, _CLASS_TYPES):
+                        patch.destination = target
+                        stack.append(patch)
+                        continue
+
+            out.append(patch)
+
+    return out
+
+
+def find_patches(modules, recursive=True):
+    """Find all the patches created through decorators.
+
+    Parameters
+    ----------
+    modules : list of module
+        Modules and/or packages to search the patches in.
+    recursive : bool
+        ``True`` to search recursively in subpackages.
+
+    Returns
+    -------
+    list of gorilla.Patch
+        Patches found.
+
+    Raises
+    ------
+    TypeError
+        The input is not a valid package or module.
+
+    See Also
+    --------
+    :func:`patch`, :func:`patches`.
+    """
+    out = []
+    modules = (
+        module for package in modules for module in _module_iterator(package, recursive=recursive)
+    )
+    for module in modules:
+        members = _get_members(module, filter=None)
+        for _, value in members:
+            base = _get_base(value)
+            decorator_data = get_decorator_data(base)
+            if decorator_data is None:
+                continue
+
+            out.extend(decorator_data.patches)
+
+    return out
+
+
+def get_attribute(obj, name):
+    """Retrieve an attribute while bypassing the descriptor protocol.
+
+    As per the built-in |getattr()|_ function, if the input object is a class
+    then its base classes might also be searched until the attribute is found.
+
+    Parameters
+    ----------
+    obj : object
+        Object to search the attribute in.
+    name : str
+        Name of the attribute.
+
+    Returns
+    -------
+    object
+        The attribute found.
+
+    Raises
+    ------
+    AttributeError
+        The attribute couldn't be found.
+
+
+    .. |getattr()| replace:: ``getattr()``
+    .. _getattr(): https://docs.python.org/library/functions.html#getattr
+    """
+    objs = inspect.getmro(obj) if isinstance(obj, _CLASS_TYPES) else [obj]
+    for obj_ in objs:
+        try:
+            return object.__getattribute__(obj_, name)
+        except AttributeError:
+            pass
+
+    raise AttributeError("'%s' object has no attribute '%s'" % (type(obj), name))
+
+
+def get_original_attribute(obj, name):
+    """Retrieve an overriden attribute that has been stored.
+
+    Parameters
+    ----------
+    obj : object
+        Object to search the attribute in.
+    name : str
+        Name of the attribute.
+
+    Returns
+    -------
+    object
+        The attribute found.
+
+    Raises
+    ------
+    AttributeError
+        The attribute couldn't be found.
+
+    See Also
+    --------
+    :attr:`Settings.allow_hit`.
+    """
+    return getattr(obj, _ORIGINAL_NAME % (name,))
+
+
+def get_decorator_data(obj, set_default=False):
+    """Retrieve any decorator data from an object.
+
+    Parameters
+    ----------
+    obj : object
+        Object.
+    set_default : bool
+        If no data is found, a default one is set on the object and returned,
+        otherwise ``None`` is returned.
+
+    Returns
+    -------
+    gorilla.DecoratorData
+        The decorator data or ``None``.
+    """
+    if isinstance(obj, _CLASS_TYPES):
+        datas = getattr(obj, _DECORATOR_DATA, {})
+        data = datas.setdefault(obj, None)
+        if data is None and set_default:
+            data = DecoratorData()
+            datas[obj] = data
+            setattr(obj, _DECORATOR_DATA, datas)
+    else:
+        data = getattr(obj, _DECORATOR_DATA, None)
+        if data is None and set_default:
+            data = DecoratorData()
+            setattr(obj, _DECORATOR_DATA, data)
+
+    return data
+
+
+def _get_base(obj):
+    """Unwrap decorators to retrieve the base object.
+
+    Parameters
+    ----------
+    obj : object
+        Object.
+
+    Returns
+    -------
+    object
+        The base object found or the input object otherwise.
+    """
+    if hasattr(obj, "__func__"):
+        obj = obj.__func__
+    elif isinstance(obj, property):
+        obj = obj.fget
+    elif isinstance(obj, (classmethod, staticmethod)):
+        # Fallback for Python < 2.7 back when no `__func__` attribute
+        # was defined for those descriptors.
+        obj = obj.__get__(None, object)
+    else:
+        return obj
+
+    return _get_base(obj)
+
+
+def _get_members(obj, traverse_bases=True, filter=default_filter, recursive=True):
+    """Retrieve the member attributes of a module or a class.
+
+    The descriptor protocol is bypassed.
+
+    Parameters
+    ----------
+    obj : module or class
+        Object.
+    traverse_bases : bool
+        If the object is a class, the base classes are also traversed.
+    filter : function
+        Attributes for which the function returns ``False`` are skipped. The
+        function needs to define two parameters: ``name``, the attribute name,
+        and ``obj``, the attribute value. If ``None``, no attribute is skipped.
+    recursive : bool
+        ``True`` to search recursively through subclasses.
+
+    Returns
+    ------
+    list of (name, value)
+        A list of tuples each containing the name and the value of the
+        attribute.
+    """
+    if filter is None:
+        filter = _true
+
+    out = []
+    stack = collections.deque((obj,))
+    while stack:
+        obj = stack.popleft()
+        if traverse_bases and isinstance(obj, _CLASS_TYPES):
+            roots = [base for base in inspect.getmro(obj) if base not in (type, object)]
+        else:
+            roots = [obj]
+
+        members = []
+        seen = set()
+        for root in roots:
+            for name, value in _iteritems(getattr(root, "__dict__", {})):
+                if name not in seen and filter(name, value):
+                    members.append((name, value))
+
+                seen.add(name)
+
+        members = sorted(members)
+        for _, value in members:
+            if recursive and isinstance(value, _CLASS_TYPES):
+                stack.append(value)
+
+        out.extend(members)
+
+    return out
+
+
+def _module_iterator(root, recursive=True):
+    """Iterate over modules.
+
+    Parameters
+    ----------
+    root : module
+        Root module or package to iterate from.
+    recursive : bool
+        ``True`` to iterate within subpackages.
+
+    Yields
+    ------
+    module
+        The modules found.
+    """
+    yield root
+
+    stack = collections.deque((root,))
+    while stack:
+        package = stack.popleft()
+        # The '__path__' attribute of a package might return a list of paths if
+        # the package is referenced as a namespace.
+        paths = getattr(package, "__path__", [])
+        for path in paths:
+            modules = pkgutil.iter_modules([path])
+            for finder, name, is_package in modules:
+                module_name = "%s.%s" % (package.__name__, name)
+                module = sys.modules.get(module_name, None)
+                if module is None:
+                    # Import the module through the finder to support package
+                    # namespaces.
+                    module = _load_module(finder, module_name)
+
+                if is_package:
+                    if recursive:
+                        stack.append(module)
+                        yield module
+                else:
+                    yield module
+
+
+def _true(*args, **kwargs):
+    """Return ``True``."""
+    return True
diff --git a/mlflow/xgboost.py b/mlflow/xgboost.py
index 4b0b767823f62..edd2e5a1f0b02 100644
--- a/mlflow/xgboost.py
+++ b/mlflow/xgboost.py
@@ -23,7 +23,6 @@
 import tempfile
 import inspect
 import logging
-import gorilla
 from copy import deepcopy
 
 import mlflow
@@ -33,6 +32,7 @@
 from mlflow.models.signature import ModelSignature
 from mlflow.models.utils import _save_example
 from mlflow.tracking.artifact_utils import _download_artifact_from_uri
+from mlflow.utils import gorilla
 from mlflow.utils.environment import _mlflow_conda_env
 from mlflow.utils.model_utils import _get_flavor_configuration
 from mlflow.exceptions import MlflowException
diff --git a/setup.py b/setup.py
index 973fbe8df2c21..ade7810d5b81a 100644
--- a/setup.py
+++ b/setup.py
@@ -55,7 +55,6 @@ def package_files(directory):
         "sqlparse>=0.3.1",
         # Required to run the MLflow server against SQL-backed storage
         "sqlalchemy<=1.3.13",
-        "gorilla",
         "prometheus-flask-exporter",
     ],
     extras_require={
diff --git a/tests/sklearn/test_sklearn_autolog.py b/tests/sklearn/test_sklearn_autolog.py
index af39e718da222..99f821ef440c8 100644
--- a/tests/sklearn/test_sklearn_autolog.py
+++ b/tests/sklearn/test_sklearn_autolog.py
@@ -8,6 +8,7 @@
 import pandas as pd
 import pytest
 import sklearn
+import sklearn.base
 import sklearn.datasets
 import sklearn.model_selection
 from scipy.stats import uniform
@@ -1033,3 +1034,47 @@ def test_autolog_does_not_capture_runs_for_preprocessing_or_imputation_estimator
     assert len(metrics) == 0
     assert len(tags) == 0
     assert len(artifacts) == 0
+
+
+@pytest.mark.large
+def test_autolog_produces_expected_results_for_estimator_when_parent_also_defines_fit():
+    """
+    Test to prevent recurrences of https://github.com/mlflow/mlflow/issues/3574
+    """
+    mlflow.sklearn.autolog()
+
+    # Construct two mock models - `ParentMod` and `ChildMod`, where ChildMod's fit() function
+    # calls ParentMod().fit() and mutates a predefined, constant prediction value set by
+    # ParentMod().fit(). We will then test that ChildMod.fit() completes and produces the
+    # expected constant prediction value, guarding against regressions of
+    # https://github.com/mlflow/mlflow/issues/3574 where ChildMod.fit() would either infinitely
+    # recurse or yield the incorrect prediction result set by ParentMod.fit()
+
+    class ParentMod(sklearn.base.BaseEstimator):
+        def get_params(self, deep=False):
+            return {}
+
+        def fit(self, X, y):
+            self.prediction = 7
+
+        def predict(self, X):
+            return self.prediction
+
+    class ChildMod(ParentMod):
+        def fit(self, X, y):
+            super().fit(X, y)
+            self.prediction = self.prediction + 1
+
+    og_all_estimators = mlflow.sklearn.utils._all_estimators()
+    new_all_estimators = og_all_estimators + [("ParentMod", ParentMod), ("ChildMod", ChildMod)]
+
+    with mock.patch("mlflow.sklearn.utils._all_estimators", return_value=new_all_estimators):
+        mlflow.sklearn.autolog()
+
+    model = ChildMod()
+    with mlflow.start_run() as run:
+        model.fit(*get_iris())
+
+    _, _, tags, _ = get_run_data(run.info.run_id)
+    assert {"estimator_name": "ChildMod"}.items() <= tags.items()
+    assert model.predict(1) == 8
diff --git a/tests/utils/test_autologging_utils.py b/tests/utils/test_autologging_utils.py
index f2c61029653d1..ecde88178301d 100644
--- a/tests/utils/test_autologging_utils.py
+++ b/tests/utils/test_autologging_utils.py
@@ -1,9 +1,9 @@
-import gorilla
 import inspect
 import pytest
 from unittest.mock import Mock, call
 
 import mlflow
+from mlflow.utils import gorilla
 from mlflow.utils.autologging_utils import (
     get_unspecified_default_args,
     log_fn_args_as_params,


Code review comments:
diff_hunk: @@ -1033,3 +1034,47 @@ def test_autolog_does_not_capture_runs_for_preprocessing_or_imputation_estimator
     assert len(metrics) == 0
     assert len(tags) == 0
     assert len(artifacts) == 0
+
+
+@pytest.mark.large
+def test_autolog_produces_expected_results_for_estimator_when_parent_also_defines_fit():
+    """
+    Test to prevent recurrences of https://github.com/mlflow/mlflow/issues/3574
+    """
+    mlflow.sklearn.autolog()
+
+    # Construct two mock models - `ParentMod` and `ChildMod`, where ChildMod's fit() function
+    # calls ParentMod().fit() and mutates a predefined, constant prediction value set by
+    # ParentMod().fit(). We will then test that ChildMod.fit() completes and produces the
+    # expected constant prediction value, guarding against regressions of
+    # https://github.com/mlflow/mlflow/issues/3574 where ChildMod.fit() would either infinitely
+    # recurse or yield the incorrect prediction result set by ParentMod.fit()
+
+    class ParentMod(sklearn.base.BaseEstimator):
+        def get_params(self, deep=False):
+            return {}
+
+        def fit(self, X, y):
+            self.prediction = 7
path: tests/sklearn/test_sklearn_autolog.py
text: @user1:
```suggestion
            self.prediction = np.array([7])
```
@author 

This is a nit but should we return a numpy array here so `infer_signature` doesn't raise the warning below?

```
Failed to infer model signature: Expected one of (pandas.DataFrame, numpy array,
dictionary of (name -> numpy.ndarray), pyspark.sql.DataFrame) but got '<class 'int'>'
```

@user1:
The test actually passes whatever `predict` returns due to `try-catch` though.

@author:
Done! Was definitely worth addressing