Repository: statsmodels/statsmodels

Issue:
BUG: GLM null uses log transformed exposure instead of exposure
https://github.com/statsmodels/statsmodels/pull/4576#issuecomment-386836344

GLMResults.null uses the model.attribute exposure without backtransforming from log

If I change it to using `exp(exposure)`, then the unit tests still pass. This means that this case is not unit tested.
There are unit tests for null_deviance but not for llnull.




Discussion:
PR coming up later today, 
something strange:

If I fix GLMResults.null directly with `exp(exposure)`, then tests pass.
If I replace kwargs by model._get_init_kwds()   (and pop family), then I get one test failure

```
_____________________ TestGlmBinomial.test_null_deviance ______________________

self = <statsmodels.genmod.tests.test_glm.TestGlmBinomial object at 0x0000000009
C7CAC8>

    def test_null_deviance(self):
        assert_almost_equal(self.res1.null_deviance, self.res2.null_deviance,
>                   self.decimal_null_deviance)
E       AssertionError:
E       Arrays are not almost equal to 4 decimals
E        ACTUAL: 35455.297320077821
E        DESIRED: 34345.3688931

..\eclipse_ws\statsmodels\statsmodels_py34_pr\statsmodels\genmod\tests\test_glm.
py:151: AssertionError
```

using pdb

```
(Pdb) self.res1.model._get_init_kwds()
{'freq_weights': array([ 1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.]), 'offset': None, 'var_weights': array([ 1.,  1.,  1.
,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,
        1.,  1.,  1.,  1.]), 'family': <statsmodels.genmod.families.family.Binom
ial object at 0x0000000009E9CE10>, 'exposure': None}
```

the only difference I can see is that we are not using WLS directly in GLM.null

```
        if len(kwargs) > 0:
            return GLM(endog, exog, family=self.family,
                       **kwargs).fit().fittedvalues
        else:
            wls_model = lm.WLS(endog, exog,
                               weights=self._iweights * self._n_trials)
            return wls_model.fit().fittedvalues
```

I don't understand why we should be using WLS if family is binomial.

AFAIR:
WLS with only a constant just computes the (prior-)weighted mean. This is the MLE if we have a canonical link, which is the case with Binomial. In this case using GLM or WLS should be equivalent for fittedvalues (params will be link transformed in one case and not in the other).

WLS will be incorrect with a non-canonical link, where we don't have (prior-weighted) mean residual =0.


`test_glm.py` has a comment, which I don't know what it means, or what's the reason for the comment
```
#TODO:
#Non-Canonical Links for the Binomial family require the algorithm to be
#slightly changed
```

???
I need some examples and unit tests to work through Null model cases.

In the binomial case, model.endog is not the original endog, but the proportion.
That is we cannot recreate the model using the attached model.endog.

```
(Pdb) self.res1.model.endog
array([ 0.56009913,  0.7826087 ,  0.59019264,  0.68935428,  0.12307692,
        0.59991099,  0.34970674,  0.61951754,  0.39047619,  0.43955014,
        0.25994695,  0.8893617 ,  0.35071942,  0.2994186 ,  0.53968254,
        0.75027027,  0.54111406,  0.44927536,  0.5503663 ,  0.55652174,
        0.36690647,  0.48997773,  0.48867314,  0.31034483,  0.18518519,
        0.07575758,  0.42970612,  0.45263158,  0.44099379,  0.57017544,
...
```
partial correction:

for computing the null prediction, deviance, llf, the link function doesn't matter if offset and exposure are zero. Any link is just a equivalent reparameterization of a constant.
e.g. binomial-Logit and binomial-Probit null_deviance are the same.

So using WLS assuming the canonical link is just the most convenient way to compute null fittedvalues, mu.
deviance and loglike only depend on mu (and scale) and don't directly depend on link function.

i.e. there is no bug in the current code by using WLS if offset and exposure are both zero.


so there are only bugs in the GLM path in GLMResults.null

- exposure not logged
- wrong endog in case of Binomial with n_trials, which is used if either offset or exposure is a model attribute e.g. `offset=np.zeros(nobs)` produces the wrong results in this case, which is the case when I used `model._get_init_kwds()` for offset and exposure.

another bug is (no full support for weights in 0.9 in some fringes)
- `null` currently ignores prior weights, freq and var_weights in the GLM path, (but not in the WLS path)


```
(Pdb) self.res1.model._get_init_kwds().keys()
dict_keys(['freq_weights', 'offset', 'var_weights', 'family', 'exposure'])
```

Resolving patch:
diff --git a/statsmodels/genmod/generalized_linear_model.py b/statsmodels/genmod/generalized_linear_model.py
--- a/statsmodels/genmod/generalized_linear_model.py
+++ b/statsmodels/genmod/generalized_linear_model.py
@@ -342,6 +342,9 @@ def __init__(self, endog, exog, family=None, offset=None,
         self._init_keys.append('family')
 
         self._setup_binomial()
+        # internal usage for recreating a model
+        if 'n_trials' in kwargs:
+            self.n_trials = kwargs['n_trials']
 
         # Construct a combined offset/exposure term.  Note that
         # exposure has already been logged if present.
@@ -921,6 +924,7 @@ def _setup_binomial(self):
             tmp = self.family.initialize(self.endog, self.freq_weights)
             self.endog = tmp[0]
             self.n_trials = tmp[1]
+            self._init_keys.append('n_trials')
 
     def fit(self, start_params=None, maxiter=100, method='IRLS', tol=1e-8,
             scale=None, cov_type='nonrobust', cov_kwds=None, use_t=None,
@@ -1528,15 +1532,14 @@ def null(self):
         endog = self._endog
         model = self.model
         exog = np.ones((len(endog), 1))
-        kwargs = {}
-        if hasattr(model, 'offset'):
-            kwargs['offset'] = model.offset
-        if hasattr(model, 'exposure'):
-            kwargs['exposure'] = model.exposure
-        if len(kwargs) > 0:
+
+        kwargs = model._get_init_kwds()
+        kwargs.pop('family')
+        if hasattr(self, '_offset_exposure'):
             return GLM(endog, exog, family=self.family,
                        **kwargs).fit().fittedvalues
         else:
+            # correct if fitted is identical across observations
             wls_model = lm.WLS(endog, exog,
                                weights=self._iweights * self._n_trials)
             return wls_model.fit().fittedvalues


Test patch:
diff --git a/statsmodels/genmod/tests/test_glm.py b/statsmodels/genmod/tests/test_glm.py
--- a/statsmodels/genmod/tests/test_glm.py
+++ b/statsmodels/genmod/tests/test_glm.py
@@ -718,6 +718,7 @@ def test_offset_exposure(self):
         mod2 = GLM(endog, exog, family=sm.families.Poisson(),
                    offset=offset2).fit()
         assert_almost_equal(mod1.params, mod2.params)
+        assert_allclose(mod1.null, mod2.null, rtol=1e-10)
 
         # test recreating model
         mod1_ = mod1.model