Repository: statsmodels/statsmodels

Issue:
Glm log log
This pull requests adds the loglog link to the glm function for the binomial family. 

Changes are
 #1 Create class LogLog and loglog
#2  Edit binimial family to use #1's class
#3 Add Loglog to tests used for other links

In addition to automated tests, results were validated against R's extendedFamily package and example from Generalized Linear Models and Extensions book.

This is my first contribution to this repo. I attempted to follow https://www.statsmodels.org/stable/dev/index.html. Feedback/corrections are welcome.

- [ ] closes #xxxx
- [ ] tests added / passed. 
- [ ] code/documentation is well formatted.  
- [ ] properly formatted commit message. See 
      [NumPy's guide](https://docs.scipy.org/doc/numpy-1.15.1/dev/gitwash/development_workflow.html#writing-the-commit-message). 

<details>


**Notes**:

* It is essential that you add a test when making code changes. Tests are not 
  needed for doc changes.
* When adding a new function, test values should usually be verified in another package (e.g., R/SAS/Stata).
* When fixing a bug, you must add a test that would produce the bug in main and
  then show that it is fixed with the new code.
* New code additions must be well formatted. Changes should pass flake8. If on Linux or OSX, you can
  verify you changes are well formatted by running 
  ```
  git diff upstream/main -u -- "*.py" | flake8 --diff --isolated
  ```
  assuming `flake8` is installed. This command is also available on Windows 
  using the Windows System for Linux once `flake8` is installed in the 
  local Linux environment. While passing this test is not required, it is good practice and it help 
  improve code quality in `statsmodels`.
* Docstring additions must render correctly, including escapes and LaTeX.

</details>



Discussion:

Resolving patch:
diff --git a/statsmodels/genmod/families/family.py b/statsmodels/genmod/families/family.py
--- a/statsmodels/genmod/families/family.py
+++ b/statsmodels/genmod/families/family.py
@@ -846,7 +846,7 @@ class Binomial(Family):
     ----------
     link : a link instance, optional
         The default link for the Binomial family is the logit link.
-        Available links are logit, probit, cauchy, log, and cloglog.
+        Available links are logit, probit, cauchy, log, loglog, and cloglog.
         See statsmodels.genmod.families.links for more information.
 
     Attributes
@@ -875,7 +875,8 @@ class Binomial(Family):
     number of trials for each row.
     """
 
-    links = [L.logit, L.probit, L.cauchy, L.log, L.cloglog, L.identity]
+    links = [L.logit, L.probit, L.cauchy, L.log, L.cloglog, L.loglog,
+             L.identity]
     variance = V.binary  # this is not used below in an effort to include n
 
     # Other safe links, e.g. cloglog and probit are subclasses
diff --git a/statsmodels/genmod/families/links.py b/statsmodels/genmod/families/links.py
--- a/statsmodels/genmod/families/links.py
+++ b/statsmodels/genmod/families/links.py
@@ -891,6 +891,141 @@ class cloglog(CLogLog):
     pass
 
 
+class LogLog(Logit):
+    """
+    The log-log transform
+
+    LogLog inherits from Logit in order to have access to its _clean method
+    for the link and its derivative.
+    """
+    def __call__(self, p):
+        """
+        Log-Log transform link function
+
+        Parameters
+        ----------
+        p : ndarray
+            Mean parameters
+
+        Returns
+        -------
+        z : ndarray
+            The LogLog transform of `p`
+
+        Notes
+        -----
+        g(p) = -log(-log(p))
+        """
+        p = self._clean(p)
+        return -np.log(-np.log(p))
+
+    def inverse(self, z):
+        """
+        Inverse of Log-Log transform link function
+
+
+        Parameters
+        ----------
+        z : array_like
+            The value of the inverse of the LogLog link function at `p`
+
+        Returns
+        -------
+        p : ndarray
+            Mean parameters
+
+        Notes
+        -----
+        g^(-1)(`z`) = exp(-exp(`z`))
+        """
+        return np.exp(-np.exp(-z))
+
+    def deriv(self, p):
+        """
+        Derivative of Log-Log transform link function
+
+        Parameters
+        ----------
+        p : array_like
+            Mean parameters
+
+        Returns
+        -------
+        g'(p) : ndarray
+            The derivative of the LogLog transform link function
+
+        Notes
+        -----
+        g'(p) = - 1 /p * log(p)
+        """
+        p = self._clean(p)
+        return -1. / (p * (np.log(p)))
+
+    def deriv2(self, p):
+        """
+        Second derivative of the Log-Log link function
+
+        Parameters
+        ----------
+        p : array_like
+            Mean parameters
+
+        Returns
+        -------
+        g''(p) : ndarray
+            The second derivative of the LogLog link function
+        """
+        p = self._clean(p)
+        d2 = (1 + np.log(p)) / (p * (np.log(p)))**2
+        return d2
+
+    def inverse_deriv(self, z):
+        """
+        Derivative of the inverse of the Log-Log transform link function
+
+        Parameters
+        ----------
+        z : array_like
+            The value of the inverse of the LogLog link function at `p`
+
+        Returns
+        -------
+        g^(-1)'(z) : ndarray
+            The derivative of the inverse of the LogLog link function
+        """
+        return np.exp(-np.exp(-z) - z)
+
+    def inverse_deriv2(self, z):
+        """
+        Second derivative of the inverse of the Log-Log transform link function
+
+        Parameters
+        ----------
+        z : array_like
+            The value of the inverse of the LogLog link function at `p`
+
+        Returns
+        -------
+        g^(-1)''(z) : ndarray
+            The second derivative of the inverse of the LogLog link function
+        """
+        return self.inverse_deriv(z) * (np.exp(-z) - 1)
+
+
+class loglog(LogLog):
+    """
+    The LogLog transform link function.
+
+    Notes
+    -----
+    g(`p`) = -log(-log(`p`))
+
+    loglog is an alias for LogLog
+    loglog = LogLog()
+    """
+    pass
+
+
 class NegativeBinomial(Link):
     '''
     The negative binomial link function


Test patch:
diff --git a/statsmodels/genmod/families/tests/test_link.py b/statsmodels/genmod/families/tests/test_link.py
--- a/statsmodels/genmod/families/tests/test_link.py
+++ b/statsmodels/genmod/families/tests/test_link.py
@@ -2,7 +2,7 @@
 Test functions for genmod.families.links
 """
 import numpy as np
-from numpy.testing import assert_allclose, assert_equal
+from numpy.testing import assert_allclose, assert_equal, assert_array_less
 import statsmodels.genmod.families as families
 from statsmodels.tools import numdiff as nd
 
@@ -17,11 +17,16 @@
 probit = links.probit()
 cauchy = links.cauchy()
 cloglog = links.CLogLog()
+loglog = links.LogLog()
 negbinom = links.NegativeBinomial()
 
 # TODO: parametrize all these tess
 Links = [logit, inverse_power, sqrt, inverse_squared, identity,
-         log, probit, cauchy, cloglog, negbinom]
+         log, probit, cauchy, cloglog, loglog, negbinom]
+
+# links with defined second derivative of inverse link.
+LinksISD = [inverse_power, sqrt, inverse_squared, identity,
+            cauchy, probit, loglog]
 
 
 def get_domainvalue(link):
@@ -31,6 +36,8 @@ def get_domainvalue(link):
     z = -np.log(np.random.uniform(0, 1))
     if isinstance(link, links.CLogLog):  # prone to overflow
         z = min(z, 3)
+    elif isinstance(link, links.LogLog):
+        z = max(z, -3)
     elif isinstance(link, links.NegativeBinomial):
         # domain is negative numbers
         z = -z
@@ -65,6 +72,10 @@ def test_deriv():
             da = nd.approx_fprime(np.r_[p], link)
             assert_allclose(d, da, rtol=1e-6, atol=1e-6,
                             err_msg=str(link))
+            if not isinstance(link, (type(inverse_power),
+                                     type(inverse_squared))):
+                # check monotonically increasing
+                assert_array_less(-d, 0)
 
 
 def test_deriv2():
@@ -98,6 +109,20 @@ def test_inverse_deriv():
                             err_msg=str(link))
 
 
+def test_inverse_deriv2():
+    # Check second derivative of inverse link using numeric differentiation.
+
+    np.random.seed(24235)
+
+    for link in LinksISD:
+        for k in range(10):
+            z = get_domainvalue(link)
+            d2 = link.inverse_deriv2(z)
+            d2a = nd.approx_fprime(np.r_[z], link.inverse_deriv)
+            assert_allclose(d2, d2a, rtol=5e-6, atol=1e-6,
+                            err_msg=str(link))
+
+
 def test_invlogit_stability():
     z = [1123.4910007309222, 1483.952316802719, 1344.86033748641,
          706.339159002542, 1167.9986375146532, 663.8345826933115,