Repository: scipy/scipy

Issue:
scipy.stats.rv_continuous.fit returns wrapper instead of fit method for some distributions
<!-- 
Thank you for taking the time to file a bug report. 
Please fill in the fields below, deleting the sections that 
don't apply to your issue. You can view the final output
by clicking the preview button above.
Note: This is a comment, and won't appear in the output.
-->

My issue is about `scipy.stats.rv_continuous` objects not returning the same type of object for the `fit` method for different distributions.

`scipy.stats.genextreme.fit` gives `<bound method rv_continuous.fit of <scipy.stats._continuous_distns.genextreme_gen object at 0x7f66083e19a0>>`
`scipy.stats.gumbel_r.fit` gives `<bound method _call_super_mom.<locals>.wrapper of <scipy.stats._continuous_distns.gumbel_r_gen object at 0x7f66083fb610>>`

This has caused issues when I pass `fit` method of one of these distributions to a function called within multiprocessing pool. genextreme works fine, but gumbel_r (and other distributions like it returning wrapper instead of fit function) now result in infinite loop (not getting an error). This change wasn't documented in the release notes.

#### Reproducing code example:
<!-- 
If you place your code between the triple backticks below, 
it will be rendered as a code block. 
-->

```python
import multiprocessing
import scipy.stats

def func(fit_method):
    fit_method([1, 2, 3])

with multiprocessing.Pool(2) as pool:
    pool.map(func, [scipy.stats.genextreme.fit])

with multiprocessing.Pool(2) as pool:
    pool.map(func, [scipy.stats.gumbel_r.fit])
```

#### Error message:
<!-- If any, paste the *full* error message inside a code block
as above (starting from line Traceback)
-->

Same for each process in the pool:

```python
Traceback (most recent call last):
  File "/usr/local/lib/python3.9/multiprocessing/process.py", line 315, in _bootstrap
    self.run()
  File "/usr/local/lib/python3.9/multiprocessing/process.py", line 108, in run
    self._target(*self._args, **self._kwargs)
  File "/usr/local/lib/python3.9/multiprocessing/pool.py", line 114, in worker
    task = get()
  File "/usr/local/lib/python3.9/multiprocessing/queues.py", line 368, in get
    return _ForkingPickler.loads(res)
AttributeError: 'gumbel_r_gen' object has no attribute 'wrapper'
```

#### Scipy/Numpy/Python version information:
<!-- You can simply run the following and paste the result in a code block
```
import sys, scipy, numpy; print(scipy.__version__, numpy.__version__, sys.version_info)
```
-->
numpy                      1.21.0
scipy                      1.7.0
python 3.9.6


Discussion:
@georgebv Oops. It wasn't documented in the release notes because it was seen as an implementation detail, but now I know that it can change the way users are able to use the methods. Do you know of anything we can do to make wrapped functions pickleable?
Just putting the snippet into each of the `fit()` methods would probably not be too onerous, given the technical constraint.

```python
        method = kwds.get('method', 'mle').lower()
        if method == 'mm':
            return super().fit(data, *args, **kwds)
```
Thank you @rkern , this indeed appears to be the thing needed. I also reviewed the code too and wanted to add a bit of context.

The issue is with the wrapper:

https://github.com/scipy/scipy/blob/5acc23400ce53ab69e97e44e4faae7f3fb039cae/scipy/stats/_continuous_distns.py#L54-L63

which is used as (multiple spots, this is example):

https://github.com/scipy/scipy/blob/5acc23400ce53ab69e97e44e4faae7f3fb039cae/scipy/stats/_continuous_distns.py#L3276-L3309
I also updated my own package where I was having this issue by doing the following:

```python
import multiprocessing
import scipy.stats

def func(distribution):
    distribution.fit([1, 2, 3])

with multiprocessing.Pool(2) as pool:
    pool.map(func, [scipy.stats.genextreme])

with multiprocessing.Pool(2) as pool:
    pool.map(func, [scipy.stats.gumbel_r])
```

This works, but feels like a hack and was pretty hard to track - my CI runs were running for hours instead of minutes, no exceptions were raised, and it took over an hour to figure out the issue.
I have another idea (inspired by this https://stackoverflow.com/questions/34253069/how-to-use-class-decorators-with-pickle). What if we simply add one line (`wrapper.__name__ = fun.__name__`) to the decorator function:

```python
def _call_super_mom(fun):
    # if fit method is overridden only for MLE and doesn't specify what to do
    # if method == 'mm', this decorator calls generic implementation
    def wrapper(self, *args, **kwds):
        method = kwds.get("method", "mle").lower()
        if method == "mm":
            return super(type(self), self).fit(*args, **kwds)
        else:
            return fun(self, *args, **kwds)

    wrapper.__name__ = fun.__name__

    return wrapper
```
As a side note, I wonder why our test suite did not pick this. IIRC we do have tests to make sure distributions are picklable

Resolving patch:
diff --git a/scipy/stats/_continuous_distns.py b/scipy/stats/_continuous_distns.py
--- a/scipy/stats/_continuous_distns.py
+++ b/scipy/stats/_continuous_distns.py
@@ -5,6 +5,7 @@
 #
 import warnings
 from collections.abc import Iterable
+from functools import wraps
 import ctypes
 
 import numpy as np
@@ -54,6 +55,7 @@ def _remove_optimizer_parameters(kwds):
 def _call_super_mom(fun):
     # if fit method is overridden only for MLE and doesn't specify what to do
     # if method == 'mm', this decorator calls generic implementation
+    @wraps(fun)
     def wrapper(self, *args, **kwds):
         method = kwds.get('method', 'mle').lower()
         if method == 'mm':


Test patch:
diff --git a/scipy/stats/tests/common_tests.py b/scipy/stats/tests/common_tests.py
--- a/scipy/stats/tests/common_tests.py
+++ b/scipy/stats/tests/common_tests.py
@@ -302,6 +302,13 @@ def check_pickling(distfn, args):
     r1 = unpickled.rvs(size=8)
     npt.assert_equal(r0, r1)
 
+    # check pickling/unpickling of .fit method
+    if hasattr(distfn, "fit"):
+        fit_function = distfn.fit
+        pickled_fit_function = pickle.dumps(fit_function)
+        unpickled_fit_function = pickle.loads(pickled_fit_function)
+        assert fit_function.__name__ == unpickled_fit_function.__name__ == "fit"
+    
     # restore the random_state
     distfn.random_state = rndm