Repository: statsmodels/statsmodels

Issue:
BUG: MNLogit fit breaks with basinhopping optimizer because of callback
https://stackoverflow.com/questions/75417415/basinhopping-causes-error-with-statsmodels-mnlogit

I can replicate the example

Problem is that callback is defined in the MNLogit.fit function and the keyword argument is ignored. That callback is inconsistent with basinhopping which calls callback with 3 args
```
    703     # BasinHoppingRunner, so run the callback
    704     if callable(callback):
--> 705         callback(bh.storage.minres.x, bh.storage.minres.fun, True)
    706 
    707     # start main iteration loop

TypeError: <lambda>() takes 1 positional argument but 3 were given
```

We cannot override the hardcoded callback using the fit keyword.

Aside:
MNLogit.fit does not call super, it calls the base model directly
`mnfit = base.LikelihoodModel.fit(self, ...`


Discussion:
minimal fix is to change in fit method

```
    if callback is None:
        callback = lambda *x : None
```

But I guess we don't need the callback if we don't call `super().fit` method of either Binary or Discrete Model.

The underlying problem is that scipy.optimize fmin_bfgs (and I guess others) has 1 arg in callback, while basinhopping has 3 args in callback. The first arg is params `x` in both.



I guess (partial) separation  for MNLogit would be if we can perfectly predict at least one level/choice of endog.

`DiscreteModel._check_perfect_pred` would need to check along 0 axis
instead of `if np.allclose(fittedvalues - endog, 0)`

np.allclose does not have an axis keyword option, so we need `max(abs(resid), axis=0)` directly for some atol.
or instead we want to check that the prediction/fittedvalues in one column is close to 1

I guess, we also need to check the reference category

(This cannot be included in a quick bugfix because we need to verify, unit test the separation case, check with other packages)




Resolving patch:
diff --git a/statsmodels/discrete/discrete_model.py b/statsmodels/discrete/discrete_model.py
--- a/statsmodels/discrete/discrete_model.py
+++ b/statsmodels/discrete/discrete_model.py
@@ -213,7 +213,7 @@ def pdf(self, X):
 
     def _check_perfect_pred(self, params, *args):
         endog = self.endog
-        fittedvalues = self.cdf(np.dot(self.exog, params[:self.exog.shape[1]]))
+        fittedvalues = self.predict(params)
         if np.allclose(fittedvalues - endog, 0):
             if self.raise_on_perfect_prediction:
                 # backwards compatibility for attr raise_on_perfect_prediction
@@ -770,7 +770,10 @@ def fit(self, start_params=None, method='newton', maxiter=35,
             start_params = np.zeros((self.K * (self.J-1)))
         else:
             start_params = np.asarray(start_params)
-        callback = lambda x : None # placeholder until check_perfect_pred
+
+        if callback is None:
+            # placeholder until check_perfect_pred
+            callback = lambda x, *args : None
         # skip calling super to handle results from LikelihoodModel
         mnfit = base.LikelihoodModel.fit(self, start_params = start_params,
                 method=method, maxiter=maxiter, full_output=full_output,


Test patch:
diff --git a/statsmodels/discrete/tests/test_discrete.py b/statsmodels/discrete/tests/test_discrete.py
--- a/statsmodels/discrete/tests/test_discrete.py
+++ b/statsmodels/discrete/tests/test_discrete.py
@@ -1552,6 +1552,19 @@ def setup_class(cls):
         cls.res2 = res2
 
 
+def test_mnlogit_basinhopping():
+    def callb(*args):
+        return 1
+
+    x = np.random.randint(0, 100, 1000)
+    y = np.random.randint(0, 3, 1000)
+    model = MNLogit(y, sm.add_constant(x))
+    # smoke tests for basinhopping and callback #8665
+    model.fit(method='basinhopping')
+    model.fit(method='basinhopping', callback=callb)
+
+
+
 def test_perfect_prediction():
     cur_dir = os.path.dirname(os.path.abspath(__file__))
     iris_dir = os.path.join(cur_dir, '..', '..', 'genmod', 'tests', 'results')