Repository: pzivich/Delicatessen (language: python)

Issue:
Support for multinomial logistic regression
**Is your feature request related to a problem? Please describe.**
Unrelated to problem.

**Describe the solution you'd like**
Addition of estimating equations for multinomial logistic regression. This is currently a major missing component for the regression support of `deli`. 

Ideally, I would integrate into `ee_regression` as an option. However, `y` is a bit of a unique matter since it takes a set of values and predicted probabilities are for each level. By processing `y` from the categories into a dummy variable behind the scenes, it becomes opaque to users. Instead, I am going to force users to dummy code `y` outside of the built-in estimating equation. This aligns the predictions a bit better. So, multinomial logit models are going to be separate `ee_...` objects

This addition will also require reconsidering how the prediction function works. A vector of predictions is no longer sufficient. We will need a matrix instead. 

**Describe alternatives you've considered**
Composing the logistic models together (doesn't work because it is a different model). We need new estimating functions for this.

**Additional context**
Below is some rough code to implement. Note that `y` has a different data structure from the other regression models. 
```python
def ee_mlogit(theta, X, y):
    X = np.asarray(X)
    y = np.asarray(y)
    theta = np.asarray(theta)
    n_y_vals = y.shape[1]
    n_x_vals = X.shape[1]

    # Compute the denominator
    start_index = 0
    exp_pred_y = []
    denom = 1
    for i in range(1, n_y_vals):
        end_index = start_index + n_x_vals
        beta_i = theta[start_index: end_index]
        pred_y = np.exp(np.dot(X, beta_i))
        denom = denom + pred_y
        exp_pred_y.append(pred_y)
        start_index = end_index

    efuncs = []
    yhat_ref = y[:, 0] - 1/denom
    for i in range(1, n_y_vals):
        yhat_i = yhat_ref + (y[:, i] - exp_pred_y[i-1]/denom)
        residual = yhat_i[:, None] * X
        efuncs.append(residual.T)

    return np.vstack(efuncs)
```
Efficient g-estimation
**Is your feature request related to a problem? Please describe.**

Currently, the g-estimation approach implemented follows the equations in Robins et al. 1992. The alternative estimating equations in Robins et al. 1994 are doubly-robust. The '94 paper provides the efficient g-estimator. In certain cases, these two estimators are asymptotically equivalent. To quote from Vansteelandt & Sjolander *Epidemiol. Methods* 2016; 5(1): 37–56

> It follows in that case that the efficient g-estimator of ψ is asymptotically equivalent (and under some conditions also mathematically identical – not shown) to the proposed estimator, i. e. the solution to eq. [18].

[18] of above is the currently implementation in `ee_gestimation_snmm`. So, these solutions won't be necessarily identical in finite samples (if my reading is correct, which also agrees with the examples I've built). What the mathematical conditions are for them to be equal is unclear to me... but doesn't really matter here.

**Describe the solution you'd like**

Request is to add the efficient g-estimator in `ee_gestimation_snmm`. To support both options (since they are not identical), there should be an optional argument, like `approach='efficient'`. While it will change future behavior, the default behavior should be the efficient estimator. This default will also make the log-linear SNMM easier (see below).

I will also need to add an argument for the outcome process model. This will only be used by the efficient estimator and what provides the double robustness and efficiency. Due to the double robustness, this model can also simply not be specified (the predicted values from this 'model' are just set to be all zeroes). So, default behavior will be no outcome process model. This will make it easier to support both g-estimators.

The following code provides a simple implementation of the efficient g-estimator for a linear SNMM.

```python
def psi(theta):
    # Breaking out parameters
    phi = theta[0:2]
    alpha = theta[2:6]
    beta = theta[6:]

    h_phi = y - np.dot(V*A[:, None], phi)

    # Propensity score model
    ee_pra = ee_regression(theta=alpha, X=Wa, y=d['A'],
                           model='logistic')
    pi_a = inverse_logit(np.dot(Wa, alpha))
    # Outcome process model
    ee_out = ee_regression(theta=beta, X=Wy, y=h_phi,
                           model='linear')
    yhat = np.dot(Wy, beta)

    # Estimating equations for SNMM
    a_resid = (A - pi_a)[:, None]
    y_resid = (y - yhat)[:, None]
    snm = np.dot(V, phi)[:, None]
    ee_snm = (a_resid * (y_resid - snm*a_resid) * V).T

    return np.vstack([ee_snm, ee_pra, ee_out])
```

Note that this issue will also help to solve #30 as the estimating equations for the efficient log-linear SNMM are provided in Section 3 of Vansteelandt & Joffe *Statistical Science* 2014; 29(4): 707–731. I have not been as successful in finding the inefficient log-linear SNMM.

**Describe alternatives you've considered**

None.

**Additional context**

None.

G-estimation of log-linear SNMM
**Is your feature request related to a problem? Please describe.**
Add log-linear SNMM support for g-estimation

**Describe the solution you'd like**
Option using the existing `model` argument. This is built in already, but I need to confirm that I have implemented correctly via some outside software.

**Describe alternatives you've considered**
Right now, there is no log-linear option supported.

**Additional context**
Related to #26 #28 

Restructure /tests
As I've added more estimating equations, the test scripts for the built-in estimating equations has become harder to manage. I am going to restructure that so that script in /estimating_equations gets its own test script. This should make management a bit easier going forward 
[BUG] ee_lasso_regression() missing return
**Describe the bug**
The `ee_lasso_regression()` in `delicatessen.estimating_equations.regression` is missing `return` at the end. 




Discussion / hints:

Fix patch:
diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
index 6dec364..5bcf372 100644
--- a/.github/workflows/python-package.yml
+++ b/.github/workflows/python-package.yml
@@ -16,7 +16,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        python-version: ["3.8", "3.9", "3.10", "3.11"]
+        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
 
     steps:
     - uses: actions/checkout@v2
diff --git a/README.md b/README.md
index d0ab0f2..11760d5 100644
--- a/README.md
+++ b/README.md
@@ -17,16 +17,16 @@ of M-estimation.
 
 ## M-Estimation and Estimating Equations
 
-Here, we provide a brief overview of M-estimation theory. For a detailed introduction to M-estimation, see Chapter 7 of
-Boos & Stefanski (2013). M-estimation is a generalization of likelihood-based methods. *M-estimators* are solutions to
-estimating equations. To apply the M-estimator, we solve the estimating equations using observed data. This is similar
-to other approaches, but the key advantage of M-Estimators is estimation of the variance via the sandwich variance.
+Here, we provide a brief overview of M-estimation theory. For more detailed introductions to M-estimation, see Ross
+et al. (2024) or Chapter 7 of Boos & Stefanski (2013). M-estimation is a generalization of likelihood-based methods.
+*M-estimators* are solutions to estimating equations. To apply the M-estimator, we solve the estimating equations using
+observed data. This is similar to other approaches, but the key advantage of M-Estimators is estimation of the variance
+via the sandwich variance.
 
 While M-Estimation is a powerful tool, the derivatives and matrix algebra can quickly become unwieldy. This is where 
-`delicatessen` comes in. `delicatessen` takes an array of estimating functions and data, and solves for the parameter
-estimates, computes the derivatives, and performs the matrix algebra calculations. Therefore, M-estimators can be more
-easily adopted without having to perform by-hand calculations. In other words, we can let the computer do the math for
-us.
+`delicatessen` comes in. `delicatessen` takes estimating functions and data, and solves for the parameter estimates,
+computes the derivatives, and performs the matrix algebra calculations. Therefore, M-estimators can be more easily
+adopted without having to perform by-hand calculations. In other words, we can let the computer do the math for us.
 
 To further ease use, `delicatessen` also comes with a variety of built-in estimating equations. See
 the [delicatessen website](https://deli.readthedocs.io/en/latest/) for details on the available estimating equations,
@@ -77,32 +77,16 @@ print(estr.variance)  # Variance estimate
 ```
 
 For further details on using `delicatessen`, see the full documentation and worked examples available
-at [delicatessen website](https://deli.readthedocs.io/en/latest/) or in the examples folder of the GitHub page.
-
-
-## Applications
-
-The following papers demonstrate practical applications of `delicatessen` and M-estimation in epidemiology:
-
-- Zivich PN, Ross RK, Shook-Sa BE, Cole SR, & Edwards JK. (2023). Empirical sandwich variance estimator for iterated
-conditional expectation g-computation. *arXiv:2306.10976*
-[CODE](https://github.com/pzivich/publications-code/tree/master/M-estimator_ICE)
-- Shook-Sa BE, Zivich PN, Rosin SP, Edwards JK, Adimora AA, Hudgens MG, & Cole SR. (2023). Fusing Trial Data for
-Treatment Comparisons: Single versus Multi-Span Bridging. *arXiv:2305.00845*
-[CODE](https://github.com/bonnieshook/BridgedTreatmentComparisons)
-- Zivich PN, Cole SR, Edwards JK, Mulholland GE, Shook-Sa BE, & Tchetgen Tchetgen EJ. (2023). Introducing Proximal
-Causal Inference for Epidemiologists. *American Journal of Epidemiology*
-[CODE](https://github.com/pzivich/publications-code/tree/master/ProximalCI)
-- Cole SR, Zivich PN, Edwards JK, Shook-Sa BE, & Hudgens MG. (2023). Sensitivity Analyses for Means or Proportions with
-Missing Outcome Data. *Epidemiology*
-[CODE](https://github.com/pzivich/publications-code/tree/master/RobinsSensitivityAnalysis)
-
+at [delicatessen website](https://deli.readthedocs.io/en/latest/).
 
 ## References
 
 Boos DD, & Stefanski LA. (2013). M-estimation (estimating equations). In Essential Statistical Inference
 (pp. 297-337). Springer, New York, NY.
 
+Ross RK, Zivich PN, Stringer JS, & Cole SR. (2024). M-estimation for common epidemiological measures: introduction and
+applied examples. *International Journal of Epidemiology*, 53(2).
+
 Stefanski LA, & Boos DD. (2002). The calculus of M-estimation. *The American Statistician*, 56(1), 29-38.
 
 Zivich PN, Klose M, Cole SR, Edwards JK, & Shook-Sa BE. (2022). Delicatessen: M-Estimation in Python.
diff --git a/delicatessen/estimating_equations/__init__.py b/delicatessen/estimating_equations/__init__.py
index b6f3e2a..2339eef 100644
--- a/delicatessen/estimating_equations/__init__.py
+++ b/delicatessen/estimating_equations/__init__.py
@@ -7,7 +7,10 @@
 from .dose_response import (ee_4p_logistic, ee_3p_logistic, ee_2p_logistic,
                             ee_effective_dose_delta)
 
-from .regression import (ee_regression, ee_glm,
+from .measurement import (ee_rogan_gladen,
+                          )
+
+from .regression import (ee_regression, ee_glm, ee_mlogit,
                          ee_robust_regression,
                          ee_ridge_regression, ee_lasso_regression, ee_elasticnet_regression, ee_bridge_regression,
                          ee_additive_regression)
diff --git a/delicatessen/estimating_equations/basic.py b/delicatessen/estimating_equations/basic.py
index 399d1f4..7047c1e 100644
--- a/delicatessen/estimating_equations/basic.py
+++ b/delicatessen/estimating_equations/basic.py
@@ -1,3 +1,7 @@
+#####################################################################################################################
+# Estimating functions for basic statistics
+#####################################################################################################################
+
 import warnings
 
 import numpy as np
diff --git a/delicatessen/estimating_equations/causal.py b/delicatessen/estimating_equations/causal.py
index 1a51296..7e2fa63 100644
--- a/delicatessen/estimating_equations/causal.py
+++ b/delicatessen/estimating_equations/causal.py
@@ -1,3 +1,7 @@
+#####################################################################################################################
+# Estimating functions for causal inference applications
+#####################################################################################################################
+
 import numpy as np
 
 from .regression import ee_regression, ee_glm
@@ -73,7 +77,7 @@ def ee_gformula(theta, y, X, X1, X0=None, force_continuous=False):
     theta : ndarray, list, vector
         Theta consists of 1+b values if ``X0`` is ``None``, and 3+b values if ``X0`` is not ``None``.
     y : ndarray, list, vector
-        1-dimensional vector of n observed values. The Y values should all be 0 or 1.
+        1-dimensional vector of n observed values.
     X : ndarray, list, vector
         2-dimensional vector of n observed values for b variables.
     X1 : ndarray, list, vector
@@ -770,62 +774,78 @@ def ee_aipw(theta, y, A, W, X, X1, X0, truncate=None, force_continuous=False):
                       m_model))          # theta[c] is for the outcome model coefficients
 
 
-def ee_gestimation_snmm(theta, y, A, W, V, model='linear', weights=None):
-    r"""Estimating equations for g-estimation of structural nested mean models. The parameter(s) of interest are the
-    parameter(s) of the corresponding structural nested mean model (SNMM). Rather than estimating the causal effect of
-    treating everyone versus treating no one, g-estimation of SNMM estimates the causal effect within strata of a set
-    of covariates, :math:`V`. Options for the SNMM include the linear SNMM and the log-linear SNMM. The linear
-    structural nested mean model is defined as
+#################################################################
+# Causal Inference (SMM) Estimating Equations
+
+def ee_gestimation_snmm(theta, y, A, W, V, X=None, model='linear', weights=None):
+    r"""Estimating equations for g-estimation of structural mean models (SMMs). The parameter(s) of interest are the
+    parameter(s) of the corresponding SMM. Rather than estimating the average causal effect, g-estimation of SMM
+    estimates the average causal effect in the acted on within strata of a set of covariates, :math:`V`. Options for
+    SMM include the linear SMM and the log-linear SMM. The linear SMM is defined as
 
     .. math::
 
-        E[Y^a - Y^{a=0} | V] = \beta_1 a + \beta_2 a V
+        E[Y^a - Y^{0} | A=a, V] = \beta_1 a + \beta_2 a V
 
-    This model corresponds to the average casual effect or causal risk difference within strata of :math:`V`. The
-    log-linear structural nested mean model is defined as
+    This model corresponds to the average causal effect among those with :math:`A=a` within strata of :math:`V`. The
+    log-linear SMM is defined as
 
     .. math::
 
-        \frac{E[Y^a | A=a, V]}{E[Y^{a=0} | A=a, V]} = \exp(\beta_1 a + \beta_2 a V)
+        \frac{E[Y^a | A=a, V]}{E[Y^{0} | A=a, V]} = \exp(\beta_1 a + \beta_2 a V)
 
-    This model corresponds to the average casual mean ratio or causal risk ratio within strata of :math:`V`. Note that
-    the log-linear SNMM is only defined when :math:`Y > 0`.
+    This model corresponds to the causal mean ratio among those with :math:`A=a` within strata of :math:`V`. Note that
+    the log-linear SMM is only defined when :math:`Y > 0`. The parameters of either SMM can be identified under the
+    assumptions of  causal consistency, and exchangeability with positivity.
 
-    Under the assumption of causal consistency, conditional exchangeability and positivity, we can solve for
-    :math:`\beta` using the following estimating equation
+    Two different estimating equations are available for g-estimation. The first set is referred to at the 'inefficient'
+    g-estimator. For the inefficient g-estimator we solve for :math:`\beta` in the following estimating equation
 
     .. math::
 
-        \sum_{i=1}^n \left\{ H(\beta) \times (A - \Pr(A | W)) \right\}  \times \mathbb{V}_i = 0
+        \sum_{i=1}^n \left\{ H(\beta) \times (A - E[A | W]) \right\}  \times \mathbb{V}_i = 0
 
-    where :math:`H(\beta) = Y - \beta A \mathbb{V}`, and :math:`\mathbb{V}` is a design matrix for the SNMM (note: this
-    design matrix should be for the case of :math:`a=1`). Note that :math:`V \subseteq W`. This estimating equation
-    requires :math:`\Pr(A | W)` or the propensity scores, which must be estimated. This is done via the following
-    estimating equation
+    where :math:`H(\beta) = Y - \beta A \mathbb{V}` for a linear SMM and
+    :math:`H(\beta) = Y \times \exp(-A \beta \mathbb{V})` for a log-linear SMM, where :math:`\mathbb{V}` is a design
+    matrix. Note that :math:`V \subseteq W`, where :math:`W` is the set of confounding variables. This estimating
+    equation requires :math:`E[A|W]`, which must be estimated. This is done via the following estimating equation for
+    binary actions
 
     .. math::
 
         \sum_{i=1}^n \left\{ A_i - \text{expit}(W_i^T \alpha) \right\} W_i = 0
 
     These estimating equations are stacked together. Therefore, the length of the parameter vector is b+c, where b is
-    the number of columns in :math:`\mathbb{V}`, and c is the number of columns in ``W``. The *first* b values in theta
-    vector are the SNMM parameters. The *second* set are the parameters corresponding to the propensity score model.
+    the number of columns in ``V``, and c is the number of columns in ``W``. The *first* b values in theta
+    vector are the SMM parameters. The *second* set are the parameters corresponding to the :math:`E[A|W]` model.
+
+    The second implementation for g-estimation is the 'efficient' g-estimator. For the efficient g-estimator we replace
+    :math:`H(\beta)` with :math:`\{H(\beta) - E[H(\beta) | W]\}` in the prior estimating equation. Here, we also need to
+    specify a model for :math:`E[H(\beta) | W]`. Therefore, an additional estimating equation for
+    :math:`E[H(\beta) | W]` is stacked with the others. Therefore, there are b+c+d parameters for the efficient
+    g-estimator, where d is the number of parameters in the model for :math:`E[H(\beta) | W]`.
 
     Parameters
     ----------
     theta : ndarray, list, vector
         Theta consists of 1+b values if ``X0`` is ``None``, and 3+b values if ``X0`` is not ``None``.
     y : ndarray, list, vector
-        1-dimensional vector of n observed values.
+        1-dimensional vector of n observed values of the outcome.
     A : ndarray, list, vector
-        1-dimensional vector of n observed values. The A values should all be 0 or 1.
+        1-dimensional vector of n observed values of the action. The A values should all be 0 or 1.
     W : ndarray, list, vector
-        2-dimensional vector of n observed values for b variables.
+        2-dimensional vector of n observed values for b columns of a design matrix to model the expected value of ``A``.
     V : ndarray, list, vector
-        2-dimensional vector of n observed values for b variables.
+        2-dimensional vector of n observed values for b columns of a design matrix for the structural mean model. Note
+        that the design matrix here is expected to not include the observed values of ``A``
+    X : ndarray, list, vector, None, optional
+        Default of this argument is ``None``, which implements the estimating equation for the inefficient g-estimator.
+        To use the efficient g-estimator, a 2-dimensional vector of n observed values for b columns of a design matrix
+        for the :math:`E[H(\beta) | W]` model should be provided here.
     model : str, optional
-        Type of structural nested mean model to fit. Options are currently only ``linear``, with a default
-        of ``linear``.
+        Type of structural mean model to fit. Options are currently: ``linear``, ``poisson``. Default is ``linear``.
+        The Poisson model specification can be used for positive continuous data, or with binary data in order to
+        estimate causal risk ratios.
     weights : ndarray, list, vector, None, optional
         1-dimensional vector of n weights. Default is None, which assigns a weight of 1 to all observations. This
         argument is intended to support the use of sampling or missingness weights.
@@ -833,7 +853,7 @@ def ee_gestimation_snmm(theta, y, A, W, V, model='linear', weights=None):
     Returns
     -------
     array :
-        Returns a (b+c)-by-n NumPy array evaluated for the input ``theta``
+        Returns a (b+c)-by-n (inefficient) or (b+c+d)-by-n (efficient) NumPy array evaluated for the input ``theta``
 
     Examples
     --------
@@ -849,29 +869,29 @@ def ee_gestimation_snmm(theta, y, A, W, V, model='linear', weights=None):
 
     >>> n = 200
     >>> d = pd.DataFrame()
-    >>> d['X'] = np.random.normal(size=n)
-    >>> d['W'] = np.random.binomial(1, p=0.5, size=n)
-    >>> d['A'] = np.random.binomial(1, p=logistic.cdf(0.25 + 0.5*d['W'] + d['X']), size=n)
-    >>> d['Ya0'] = np.random.binomial(1, p=logistic.cdf(0.75 - 0.5 * d['W'] + d['X']), size=n)
-    >>> d['Ya1'] = np.random.binomial(1, p=logistic.cdf(0.75 - 0.2 * d['W'] + d['X'] - 0.1 * 1), size=n)
+    >>> d['W'] = np.random.normal(size=n)
+    >>> d['V'] = np.random.binomial(1, p=0.5, size=n)
+    >>> d['A'] = np.random.binomial(1, p=logistic.cdf(0.25 + 0.5*d['V'] + d['W']), size=n)
+    >>> d['Ya0'] = 12.75 - 3.5*d['V'] + d['W'] + np.random.normal(size=n)
+    >>> d['Ya1'] = 10.75 - 0.8*d['V'] + d['W'] + np.random.normal(size=n)
     >>> d['Y'] = (1-d['A'])*d['Ya0'] + d['A']*d['Ya1']
     >>> d['C'] = 1
 
     Defining psi, or the stacked estimating equations. Note that ``A`` is the action of interest and ``Y`` is the
-    outcome of interest. Here, we are interested in estimating the following SNMM
+    outcome of interest. Here, we are interested in estimating the following linear SMM
 
     .. math::
 
-        E[Y_i^a - Y_i^{a=0} | W_i] = \beta_1 a + \beta_2 a W_i
+        E[Y^a - Y^{0} | A=a, V] = \beta_1 a + \beta_2 a V
 
     >>> def psi(theta):
     >>>     return ee_gestimation_snmm(theta,
     >>>                                y=d['Y'], A=d['A'],
-    >>>                                W=d[['C', 'W', 'X']],
-    >>>                                V=d[['C', 'W']])
+    >>>                                W=d[['C', 'V', 'W']],
+    >>>                                V=d[['C', 'V']])
 
-    Calling the M-estimator. AIPW has 2 coefficients in the SNMM, and 3 coefficients in the propensity score model. So,
-    the total number of initial values should be 2+3=5.
+    Calling the M-estimator.  Since there are 2 coefficients in the SMM and 3 coefficients in the :math:`E[A|W]` model,
+    the total number of initial values should be 2+3=5:
 
     >>> estr = MEstimator(psi,
     >>>                   init=[0., ]*5)
@@ -885,11 +905,26 @@ def ee_gestimation_snmm(theta, y, A, W, V, model='linear', weights=None):
 
     More specifically, the corresponding parameters are
 
-    >>> estr.theta[0]     # beta_1 of SNMM
-    >>> estr.theta[1]     # beta_2 of SNMM
+    >>> estr.theta[0]     # beta_1 of SMM
+    >>> estr.theta[1]     # beta_2 of SMM
     >>> estr.theta[2:]    # propensity score regression coefficients
 
-    The causal risk ratio in this example can be estimated by specifying ``model='log'``.
+    The efficient g-estimator can be implemented by providing a design matrix to the argument ``X``
+
+    >>> def psi(theta):
+    >>>     return ee_gestimation_snmm(theta,
+    >>>                                y=d['Y'], A=d['A'],
+    >>>                                W=d[['C', 'V', 'W']],
+    >>>                                V=d[['C', 'V']],
+    >>>                                X=d[['C', 'V', 'W']])
+
+    Here, there are 2+3+3=8 parameters to estimate
+
+    >>> estr = MEstimator(psi,
+    >>>                   init=[0., ]*8)
+    >>> estr.estimate(solver='lm')
+
+    A log-linear SMM for this example can be estimated by specifying ``model='poisson'``.
 
     References
     ----------
@@ -901,53 +936,81 @@ def ee_gestimation_snmm(theta, y, A, W, V, model='linear', weights=None):
 
     Vansteelandt S, & Joffe M (2014). Structural nested models and G-estimation: the partially realized promise.
     *Statist Sci*, 29(4), 707-731.
+
+    Vansteelandt S, & Sjolander A (2016). Revisiting g-estimation of the effect of a time-varying exposure subject to
+    time-varying confounding. *Epidemiologic Methods*, 5(1), 37-56.
     """
+    # Future consideration: add bias adjustment via b(A,W; \alpha) to h_psi from Vancak & Sjolander
     # Ensuring correct typing
     y = np.asarray(y)[:, None]                  # Convert to NumPy array and converting shape
     A = np.asarray(A)                           # Convert to NumPy array
     W = np.asarray(W)                           # Convert to NumPy array
     V = np.asarray(V)                           # Convert to NumPy array
-    pdiv = V.shape[1]                           # Extracting number of SNM parameters
-
-    # Extracting theta value for ease
-    beta = np.asarray(theta[0: pdiv])[:, None]  # theta parameters for the SNM
-    alpha = np.asarray(theta[pdiv:])            # theta parameters for the propensity score model
-
-    # Propensity Score Model
-    ee_log = ee_regression(theta=alpha,         # Propensity score parameters
-                           X=W, y=A,            # ... treatment and covariate design matrix
-                           model='logistic',    # ... logistic model
-                           weights=weights)     # ... with provided weights
-    pi = inverse_logit(np.dot(W, alpha))        # Converting log-odds to probability
-
-    # Option for the variations on the structural nested mean model
-    # Future consideration: add bias adjustment via b(A,W; \alpha) to h_psi from Vancak & Sjolander
-    if model == 'linear':                                      # Linear structural nested mean model
-        h_psi = y - identity(np.dot(V*A[:, None], beta))       # ... subtract and identity transformation
-    # Holding off on releasing the log-linear since I can't find a good external comparison yet...
-    # elif model == 'log':                                       # Log-linear structural nested mean model
-    #     h_psi = y * np.exp(-1 * np.dot(V*A[:, None], beta))    # ... multiplication and exp transformation
-    else:                                                      # Error checking
-        # Note: logistic SNMM are possible, but these require specifying an outcome model.
-        #   ... To keep the structure of SNMM as-is, I do not provide that option.
-        #   ... Further a double-robust g-estimation algorithm exists but is not implemented here.
-        raise ValueError("model='" + str(model) + "' is not a supported option. "
-                         "Only the following options are supported: "
-                         "linear")
+    eq_add = []                                 # Storage for outcome model, default is empty (none)
+    pdiv = V.shape[1]                           # Extracting number of SMM parameters
+    qdiv = W.shape[1] + pdiv                    # Extracting number of E[A|W] parameters
 
     # Processing weights argument
     if weights is None:
         weight = 1
     else:
-        weight = weights
+        weight = np.asarray(weights)
 
-    # Computing estimating functions for the corresponding structural nested mean model
-    ee_snm = weight*(h_psi * (A - pi)[:, None] * V).T  # Computing estimating function
+    # Extracting theta value for ease
+    phi = np.asarray(theta[0: pdiv])[:, None]   # theta parameters for the SMM
+    alpha = np.asarray(theta[pdiv:qdiv])        # theta parameters for the E[A|W] model
+    if X is not None:                           # If given an input X
+        beta = np.asarray(theta[qdiv:])         # ... theta parameters for the E[Y|W] model
+
+    # # Option for the variations on the structural mean model
+    if model.lower() == 'linear':                             # Linear structural mean model
+        h_phi = y - np.dot(V*A[:, None], phi)                 # ... simply subtract
+        y_transform = identity                                # ... transformation for E[h(phi) | X]
+    elif model.lower() == 'poisson':                          # Log-linear structural mean model
+        h_phi = y * np.exp(-1 * np.dot(V*A[:, None], phi))    # ... multiplication and exp transformation
+        y_transform = np.exp                                  # ... transformation for E[h(phi) | X]
+    # Add tanh(.) as a function for the risk difference?
+    else:                                                     # Error checking
+        raise ValueError("model='" + str(model) + "' is not a "
+                         "supported option. Only the following "
+                         "options are supported: linear, poisson")
+
+    # Estimating the E[A | L] Model
+    ee_log = ee_regression(theta=alpha,                              # Propensity score parameters
+                           X=W, y=A,                                 # ... treatment and covariate design matrix
+                           model='logistic',                         # ... logistic model
+                           weights=weights)                          # ... with provided weights
+    pi = inverse_logit(np.dot(W, alpha))                             # Converting log-odds to probability
+    a_resid = (A - pi)[:, None]                                      # Calculating residuals for A
+
+    # Estimating functions for the corresponding g-estimator of SMM
+    if X is not None:                                            # Specifying an outcome model for efficient
+        X = np.asarray(X)                                        # ... convert X to NumPy array
+        ee_out = ee_regression(theta=beta,                       # ... outcome model with beta
+                               X=X, y=h_phi[:, 0],               # ... for E[h(phi)|W]
+                               model=model,                      # ... transformation to consider
+                               weights=weights)                  # ... using provided weights
+        yhat = y_transform(np.dot(X, beta)[:, None])             # ... get predicted h(phi)
+        eq_add = [ee_out, ]                                      # ... adding outcome model estimating functions
+    else:                                                        # Otherwise uses inefficient g-estimator
+        yhat = 0                                                 # ... residual set manually to zero
+        # This error should not be reached at this time. It is a placeholder for a potential future addition
+        if model.lower() == 'logistic':                          # Error if logistic is requested
+            raise ValueError("The g-estimator with X=None "
+                             "does not support logistic structural mean models.")
+
+    # Estimating function for the structural mean model
+    y0_resid = h_phi - yhat
+    ee_smm = weight * (a_resid * y0_resid * V).T
 
     # Output (b+c)-by-n array
-    return np.vstack([ee_snm,                          # SNMM parameters
-                      ee_log])                         # Propensity score parameters
+    stacked_ee = [ee_smm,            # SMM parameters
+                  ee_log] + eq_add   # Nuisance model parameters
+    return np.vstack(stacked_ee)
+
 
+#################################################################
+# Causal Inference (Sensitivity Analysis) Estimating Equations
 
 def ee_mean_sensitivity_analysis(theta, y, delta, X, q_eval, H_function):
     r"""Estimating equation for weighted sensitivity analysis estimator of the mean. This estimator can handle cases of
diff --git a/delicatessen/estimating_equations/dose_response.py b/delicatessen/estimating_equations/dose_response.py
index b482e86..405efa5 100644
--- a/delicatessen/estimating_equations/dose_response.py
+++ b/delicatessen/estimating_equations/dose_response.py
@@ -1,3 +1,7 @@
+#####################################################################################################################
+# Estimating functions for dose-response analyses
+#####################################################################################################################
+
 import numpy as np
 
 #################################################################
diff --git a/delicatessen/estimating_equations/measurement.py b/delicatessen/estimating_equations/measurement.py
new file mode 100644
index 0000000..bc88930
--- /dev/null
+++ b/delicatessen/estimating_equations/measurement.py
@@ -0,0 +1,140 @@
+#####################################################################################################################
+# Estimating functions for measurement error
+#####################################################################################################################
+
+import numpy as np
+
+from delicatessen.utilities import inverse_logit
+from delicatessen.estimating_equations.regression import ee_regression
+from delicatessen.estimating_equations.processing import generate_weights
+
+
+def ee_rogan_gladen(theta, y, y_star, r, weights=None):
+    r"""Estimating equation for the Rogan-Gladen correction for mismeasured *binary* outcomes. This estimator uses
+    external data to estimate the sensitivity and specificity, and then uses those external estimates to correct the
+    estimated proportion. The general form of the estimating equations are
+
+    .. math::
+
+        \sum_{i=1}^n
+        \begin{bmatrix}
+            \mu \times \left\{ \alpha + \beta - 1 \right\} - \left\{ \mu^* + \beta - 1 \right\} \\
+            R_i (Y_i^* - \mu^*) \\
+            (1-R_i) Y_i \left\{ Y^*_i - \beta \right\} \\
+            (1-R_i) (1-Y_i) \left\{ Y^*_i - \alpha \right\} \\
+        \end{bmatrix}
+        = 0
+
+    where :math:`Y` is the true value of the outcome, :math:`Y^*` is the mismeasured value of the outcome. The first
+    estimating equation is the corrected proportion, the second is the naive proportion, the third is for sensitivity,
+    and the fourth for specificity.
+
+    Here, :math:`\theta` is a 1-by-4 array.
+
+    Note
+    ----
+    The Rogan-Gladen estimator may provide corrected proportions outside of :math:`[0,1]` when
+    :math:`\alpha + \beta \le 1`, or the addition of sensitivity and specificity is less than or equal to one.
+
+    Parameters
+    ----------
+    theta : ndarray, list, vector
+        Theta consists of 2+2b values. If ``X=None`` then 4 values should be provided.
+    y : ndarray, list, vector
+        1-dimensional vector of n observed values. These are the gold-standard :math:`Y` measurements in the external
+        sample. All values should be either 0 or 1, and be non-missing among those with :math:`R=0`.
+    y_star : ndarray, list, vector
+        1-dimensional vector of n observed values. These are the mismeasured :math:`Y` values. All values should be
+        either 0 or 1, and be non-missing among all observations.
+    r : ndarray, list, vector
+        1-dimensional vector of n indicators regarding whether an observation was part of the external validation data.
+        Indicator should designate if observations are the main data.
+    weights : ndarray, list, vector, None, optional
+        1-dimensional vector of n weights. Default is ``None``, which assigns a weight of 1 to all observations.
+
+    Returns
+    -------
+    array :
+        Returns a 4-by-n NumPy array evaluated for the input ``theta``
+
+    Examples
+    --------
+    Construction of a estimating equation(s) with ``ee_rogan_gladen`` should be done similar to the following
+
+    >>> import numpy as np
+    >>> import pandas as pd
+    >>> from scipy.stats import logistic
+    >>> from delicatessen import MEstimator
+    >>> from delicatessen.estimating_equations import ee_rogan_gladen
+
+    Replicating the example from Cole et al. (2023).
+
+    >>> d = pd.DataFrame()
+    >>> d['Y_star'] = [0, 1] + [0, 1, 0, 1]
+    >>> d['Y'] = [np.nan, np.nan] + [0, 0, 1, 1]
+    >>> d['S'] = [1, 1] + [0, 0, 0, 0]
+    >>> d['n'] = [270, 680] + [71, 18, 38, 203]
+    >>> d = pd.DataFrame(np.repeat(d.values, d['n'], axis=0), columns=d.columns)
+
+    Applying the Rogan-Gladen correction to this example
+
+    >>> def psi(theta):
+    >>>     return ee_rogan_gladen(theta=theta, y=d['Y'],
+    >>>                            y_star=d['Y_star'], r=d['S'])
+
+    Notice that ``y`` corresponds to the gold-standard outcomes (only available where R=0), ``y_star`` corresponds to
+    the mismeasured covariate data (available for R=1 and R=0), and ``r`` corresponds to the indicator for the main
+    data source. Now we can call the M-Estimator.
+
+    >>> estr = MEstimator(psi, init=[0.5, 0.5, .75, .75])
+    >>> estr.estimate(solver='lm')
+
+    Inspecting the parameter estimates, variance, and 95% confidence intervals
+
+    >>> estr.theta
+    >>> estr.variance
+    >>> estr.confidence_intervals()
+
+    The corrected proportion is
+
+    >>> estr.theta[0]
+
+    Inverse probability weights can be used through the ``weights`` argument.
+
+    References
+    ----------
+    Cole SR, Edwards JK, Breskin A, Rosin S, Zivich PN, Shook-Sa BE, & Hudgens MG. (2023). Illustration of 2 Fusion
+    Designs and Estimators. *American Journal of Epidemiology*, 192(3), 467-474.
+
+    Rogan WJ & Gladen B. (1978). Estimating prevalence from the results of a screening test.
+    *American Journal of Epidemiology*, 107(1), 71-76.
+    """
+    # Processing inputs
+    y = np.asarray(y)                           # Convert to NumPy array
+    y_star = np.asarray(y_star)                 # Convert to NumPy array
+    r = np.asarray(r)                           # Convert to NumPy array
+    y = np.where(r == 1, -999, y)               # Removing NaN (or any other indicators) for Y in main
+    w = generate_weights(weights=weights,       # Formatting weights
+                         n_obs=y.shape[0])      # ... and processing if None
+    mu = theta[0]                               # Parameter for corrected proportion
+    mu_star = theta[1]                          # Means (corrected and naive)
+    sens = theta[2]                             # Parameters for the sensitivity model
+    spec = theta[3]                             # Parameters for the specificity model
+
+    # Estimating equation for naive (mismeasured) mean
+    ee_naive_mean = r * w * (y_star - mu_star)  # Naive mean among obs
+
+    # Estimating equation for sensitivity
+    ee_sens = (y_star - sens) * (1-r) * y * w
+
+    # Estimating equation for specificity
+    ee_spec = (1 - y_star - spec) * (1-r) * (1-y) * w
+
+    # Estimating equation for the Rogan-Gladen correction
+    ee_corr_mean = mu*(sens + spec - 1) - (mu_star + spec - 1) * np.ones(y.shape[0])
+
+    # Returning stacked estimating equations
+    return np.vstack([ee_corr_mean,             # Corrected mean
+                      ee_naive_mean,            # Naive mean
+                      ee_sens,                  # Sensitivity model parameters
+                      ee_spec])                 # Specificity model parameters
diff --git a/delicatessen/estimating_equations/regression.py b/delicatessen/estimating_equations/regression.py
index f675335..11a4e05 100644
--- a/delicatessen/estimating_equations/regression.py
+++ b/delicatessen/estimating_equations/regression.py
@@ -1,6 +1,9 @@
+#####################################################################################################################
+# Estimating functions for regression models
+#####################################################################################################################
+
 import warnings
 import numpy as np
-from scipy.stats import norm, cauchy
 
 from delicatessen.utilities import (logit, inverse_logit, identity,
                                     robust_loss_functions,
@@ -176,7 +179,7 @@ def ee_glm(theta, X, y, distribution, link, hyperparameter=None, weights=None, o
     y : ndarray, list, vector
         1-dimensional vector of n observed values.
     distribution : str
-        Distribution for the generalized linear model. Options are
+        Distribution for the generalized linear model. Options are:
         ``'normal'`` (alias: ``gaussian``),
         ``'binomial'`` (aliases: ``bernoulli``, ``bin``),
         ``'poisson'``,
@@ -185,7 +188,7 @@ def ee_glm(theta, X, y, distribution, link, hyperparameter=None, weights=None, o
         ``'negative_binomial'`` (alias: ``nb``),
         and ``'tweedie'``.
     link : str
-        Link function for the generalized linear model. Options are
+        Link function for the generalized linear model. Options are:
         ``identity``,
         ``log``,
         ``logistic`` (alias: ``logit``),
@@ -196,8 +199,8 @@ def ee_glm(theta, X, y, distribution, link, hyperparameter=None, weights=None, o
         ``inverse``,
         and ``square_root`` (alias: ``sqrt``).
     hyperparameter : None, int, float
-        Hyperparameter specification. Default is None. This option is only used by the tweedie and negative-binomial
-        distributions.
+        Hyperparameter specification. Default is None. This option is only used by the tweedie distribution. It is
+        ignored by all other distributions.
     weights : ndarray, list, vector, None, optional
         1-dimensional vector of n weights. Default is None, which assigns a weight of 1 to all observations.
     offset : ndarray, list, vector, None, optional
@@ -219,13 +222,95 @@ def ee_glm(theta, X, y, distribution, link, hyperparameter=None, weights=None, o
 
     >>> import numpy as np
     >>> import pandas as pd
-    >>> from scipy.stats import logistic
     >>> from delicatessen import MEstimator
     >>> from delicatessen.estimating_equations import ee_glm
 
     Some generic data to estimate the regression models
 
-    >>>
+    >>> d = pd.DataFrame()
+    >>> d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
+    >>> d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
+    >>> d['Y1'] = [1, 2, 4, 5, 2, 3, 1, 1, 3, 4, 2, 3, 7, 8, 2, 2, 1, 4, 2, 1]
+    >>> d['Y2'] = [1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 0, 0]
+    >>> d['C'] = 1
+    >>> X = d[['C', 'X', 'Z']]  # design matrix used hereafter
+
+    To start, we will demonstrate a GLM with a normal distribution and identity link. This GLM is equivalent to linear
+    regression. Defining psi, or the stacked estimating equations
+
+    >>> def psi(theta):
+    >>>     return ee_glm(theta, X=X, y=d['Y1'],
+    >>>                   distribution='normal', link='identity')
+
+    Calling the M-estimator (note that ``init`` requires 3 values, since ``X.shape[1]`` is 3).
+
+    >>> estr = MEstimator(stacked_equations=psi, init=[0., 0., 0.,])
+    >>> estr.estimate()
+
+    Inspecting the parameter estimates, variance, and confidence intervals
+
+    >>> estr.theta
+    >>> estr.variance
+    >>> estr.confidence_intervals()
+
+    Next, we will show a GLM with a binomial distribution and log link. This GLM can be used for binary data and
+    estimates log risk ratios. So, one may prefer this model for interpretability over logistic regression (GLM with
+    binomial distribution and logit link).
+
+    >>> def psi(theta):
+    >>>     return ee_glm(theta, X=X, y=d['Y2'],
+    >>>                   distribution='binomial', link='log')
+
+    Calling the M-estimator (note that ``init`` requires 3 values, since ``X.shape[1]`` is 3).
+
+    >>> estr = MEstimator(stacked_equations=psi, init=[-0.9, 0., 0.,])
+    >>> estr.estimate()
+
+    Notice that the root-finding solution may go off to weird places if bad starting values are given for the
+    log-binomial GLM. This is because the log-binomial GLM is not bounded. Providing starting values close to the truth
+    (or changing link functions) can help alleviate these issues. Other variations for binomial distribution link
+    functions that are bounded include: ``logit``, ``cauchy``, ``probit``, or ``loglog``.
+
+    The negative-binomial and gamma distributions for GLM have an additional parameter that is estimated. Therefore,
+    both of these distribution specifications require ``X.shape[1] + 1`` input starting values. Here, we illustrate
+    a gamma distribution and log link GLM
+
+    >>> def psi(theta):
+    >>>     return ee_glm(theta, X=X, y=d['Y1'],
+    >>>                   distribution='gamma', link='log')
+
+    Calling the M-estimator (note that ``init`` requires 4 values, since ``X.shape[1]`` is 3 and the gamma distribution
+    has an additional parameter).
+
+    >>> estr = MEstimator(stacked_equations=psi, init=[0., 0., 0., 0.])
+    >>> estr.estimate()
+
+    Note that ``delicatessen`` appropriately incorporates the estimation of the additional parameter for the
+    negative-binomial and gamma distributions. This is unlike some statistical software that estimates this parameter
+    but does *not* incorporate the uncertainty in estimation of that parameter. This may explain differences you
+    encounter across software (and the ``delicatessen`` implementation is preferred, as it is a more honest expression
+    of the uncertainty).
+
+    Finally, the tweedie distribution for GLM is a generalization of the Poisson and gamma distributions. Unlike the
+    negative-binomial and gamma distributions, there is a fixed (i.e., not estimated) hyperparameter bounded to be >0.
+    When the tweedie distribution hyperparameter is set to 1, it is equivalent to the Poisson distribution. When the
+    tweedie distribution hyperparameter is set to 2, it is equivalent to the gamma distribution. When the tweedie
+    distribution hyperparameter is set to 3, it is equivalent to the inverse-normal distribution. However, the tweedie
+    distribution hyperparameter can be specified for any values. Here, we illustrate the tweedie distribution that is
+    between a Poisson and gamma distribution.
+
+    >>> def psi(theta):
+    >>>     return ee_glm(theta, X=X, y=d['Y1'],
+    >>>                   distribution='tweedie', link='log',
+    >>>                   hyperparameter=1.5)
+
+    Calling the M-estimator (note that ``init`` requires 3 values, since ``X.shape[1]`` is 3).
+
+    >>> estr = MEstimator(stacked_equations=psi, init=[0., 0., 0.])
+    >>> estr.estimate()
+
+    Notice that the tweedie distribution does not estimate an additional parameter, unlike the gamma distribution GLM
+    described previously.
 
     References
     ----------
@@ -275,6 +360,154 @@ def ee_glm(theta, X, y, distribution, link, hyperparameter=None, weights=None, o
         return ee_beta                                                                   # ... only beta EE
 
 
+def ee_mlogit(theta, X, y, weights=None, offset=None):
+    r"""Estimating equation for multinomial logistic regression. This estimating equation functionality supports
+    unranked categorical outcome data, unlike ``ee_regression`` and ``ee_glm``.
+
+    Note
+    ----
+    Unlike the other regression estimating equations, ``ee_mlogit`` expects a matrix of indicators for each possible
+    value of ``y``, with the first column being used as the referent category. In other words, the outcome variable is
+    a matrix of dummy variables that includes the reference.
+
+
+    The estimating equation for column :math:`r` of the indicator variable :math:`Y_{r}`
+    of a :math:`Y` with :math:`k` unique categories is
+
+    .. math::
+
+        \sum_{i=1}^n \left\{ Y_{r,i} - \frac{\exp(X_i^T \theta_r)}{1 + \sum_{j=2}^{k} \exp(X_i^T \theta_j)}  \right\}
+        X_i = 0
+
+    where :math:`\theta_r` are the coefficients correspond to the log odds ratio comparing :math:`Y_r` to all other
+    categories of :math:`Y`. Here, :math:`\theta` is a 1-by-(b :math`\times` (k-1)) array, where b is the distinct
+    covariates included as part of X. So, the stack of estimating equations consists of :math:`(k-1)` estimating
+    equations of the dimension :math:`X_i`. For example, if X is a 3-by-n matrix and :math:`Y` has three unique
+    categories, then :math:`\theta` will be a 1-by-6 array.
+
+    Note
+    ----
+    All provided estimating equations are meant to be wrapped inside a user-specified function. Throughout, these
+    user-defined functions are defined as ``psi``.
+
+    Parameters
+    ----------
+    theta : ndarray, list, vector
+        Theta in this case consists of b :math:`\times` (k-1) values. Therefore, initial values should consist of the
+        same number as the number of columns present in the design matrix for each category of the outcome matrix
+        besides the reference.
+    X : ndarray, list, vector
+        2-dimensional design matrix of n observed covariates for b variables.
+    y : ndarray, list, vector
+        2-dimensional indicator matrix of n observed outcomes.
+    weights : ndarray, list, vector, None, optional
+        1-dimensional vector of n weights. Default is None, which assigns a weight of 1 to all observations.
+    offset : ndarray, list, vector, None, optional
+        A 1-dimensional offset to be included in the model. Default is None, which applies no offset term.
+
+    Returns
+    -------
+    array :
+        Returns a (b*(k-1))-by-n NumPy array evaluated for the input ``theta``
+
+    Examples
+    --------
+    Construction of a estimating equation(s) with ``ee_regression`` should be done similar to the following
+
+    >>> import numpy as np
+    >>> import pandas as pd
+    >>> from delicatessen import MEstimator
+    >>> from delicatessen.estimating_equations import ee_mlogit
+
+    Some generic data to estimate a multinomial logistic regression model
+
+    >>> d = pd.DataFrame()
+    >>> d['W'] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1]
+    >>> d['Y'] = [1, 1, 1, 1, 2, 2, 3, 3, 3, 1, 2, 2, 3, 3]
+    >>> d['C'] = 1
+
+    First, notice that ``Y`` needs to be pre-processed for use with ``ee_mlogit``. To prepare the data, we need to
+    convert ``d['Y']`` into a matrix of indicator variables. We can do this manually by
+
+    >>> d['Y1'] = np.where(d['Y'] == 1, 1, 0)
+    >>> d['Y2'] = np.where(d['Y'] == 2, 1, 0)
+    >>> d['Y3'] = np.where(d['Y'] == 3, 1, 0)
+
+    This can also be accomplished with ``pd.get_dummies(d['Y'], drop_first=False)``.
+
+    For the reference category, we want to have ``Y=1`` as the reference. Therefore, ``Y1`` will be the first column in
+    ``y``. The pair of matrices are
+
+    >>> y = d[['Y1', 'Y2', 'Y3']]
+    >>> X = d[['C', 'W']]
+
+    Defining psi, or the stacked estimating equations
+
+    >>> def psi(theta):
+    >>>     return ee_mlogit(theta, X=X, y=y)
+
+    Calling the M-estimator (note that ``init`` requires 4 values, since ``X.shape[1]`` is 2 and ``y.shape[1]`` is 3).
+
+    >>> estr = MEstimator(stacked_equations=psi, init=[0., 0., 0., 0.])
+    >>> estr.estimate()
+
+    Inspecting the parameter estimates, variance, and confidence intervals
+
+    >>> estr.theta
+    >>> estr.variance
+    >>> estr.confidence_intervals()
+
+    Here, the first two values of ``theta`` correspond to ``Y2`` and the last two values of ``theta`` correspond to
+    ``Y3``.
+
+    A weighted multinomial logistic regression can be implemented by specifying the ``weights`` argument. An offset can
+    be added by specifying the ``offset`` argument.
+
+    References
+    ----------
+    Kwak C & Clayton-Matthews A. (2002). Multinomial logistic regression. *Nursing Research*, 51(6), 404-410.
+    """
+    # Preparation of input shapes and object types
+    X, y, theta, offset = _prep_inputs_(X=X, y=y, theta=theta, penalty=None, offset=offset, reshape_y=False)
+    w = generate_weights(weights=weights, n_obs=X.shape[0])          # Compute the corresponding weight vector
+
+    # Setting up parameters for later use
+    n_y_vals = y.shape[1]                             # Number of categories in Y
+    n_x_vals = X.shape[1]                             # Number of columns / predictors in X
+    n_params = theta.shape[0]                         # Number of parameters provided
+    denom = 1                                         # Default value for denominator
+    exp_pred_y = []                                   # Storage for the expected values of Y
+    efuncs = []                                       # Storage for the stacked estimating functions
+    start_index = 0                                   # Starting index for looping over beta for all Y categories
+
+    # Checking that shapes agree to prevent user headaches
+    if (n_y_vals-1) * n_x_vals != theta.shape[0]:
+        raise ValueError("There is a mismatch in the number of provided parameters, " + str(n_params)
+                         + ", and the number of parameters for " + str(n_y_vals) + " columns of Y and "
+                         + "a design matrix with " + str(n_x_vals) + " columns. There should be "
+                         + str((n_y_vals-1) * n_x_vals) + " parameters.")
+
+    # Computing the overall denominator for the multinomial logistic model
+    for i in range(1, n_y_vals):                      # Looping over all columns of Y
+        end_index = start_index + n_x_vals            # ... get the current end_index
+        beta_i = theta[start_index: end_index]        # ... grab the corresponding beta's for Y column
+        pred_y = np.exp(np.dot(X, beta_i) + offset)   # ... generate predicted value of Y column
+        exp_pred_y.append(pred_y)                     # ... store the particular predicted values for Y
+        denom = denom + pred_y                        # ... update the denominator with summation
+        start_index = end_index                       # ... update start_index to current end_index
+
+    # Computing the stacked estimating equations for each column of Y
+    yhat_ref = y[:, 0][:, None] - 1/denom                          # Compute the residual for the reference Y
+    for i in range(1, n_y_vals):                                   # Looping over all columns of Y
+        y_reshape = y[:, i][:, None]                               # ... extract and reshape current Y indicator
+        yhat_i = yhat_ref + (y_reshape - exp_pred_y[i-1]/denom)    # ... get residuals for current Y versus reference Y
+        residual = w*(yhat_i*X).T                                  # ... expanding residuals by design matrix
+        efuncs.append(residual)                                    # ... store the current residuals
+
+    # Output b-by-n matrix
+    return np.vstack(efuncs)
+
+
 #################################################################
 # Robust Regression Estimating Equations
 
@@ -565,8 +798,8 @@ def ee_lasso_regression(theta, X, y, model, penalty, epsilon=3.e-3, weights=None
 
     Note
     ----
-    As the derivative of the estimating equation for LASSO is not defined, the bread (and sandwich) cannot be used to
-    estimate the variance in all settings.
+    As the derivative of the estimating equation for LASSO is not defined at :math:`\theta=0`, the bread (and sandwich)
+    cannot be used to estimate the variance in all settings.
 
 
     The estimating equation for the approximate LASSO linear regression is
@@ -703,12 +936,12 @@ def ee_lasso_regression(theta, X, y, model, penalty, epsilon=3.e-3, weights=None
         raise ValueError("epsilon must be greater than zero for the approximate LASSO")
 
     # Calling internal bridge penalized regression for implementation
-    ee_bridge_regression(theta=theta,
-                         X=X, y=y,
-                         model=model,
-                         weights=weights,
-                         penalty=penalty, gamma=1+epsilon, center=center,
-                         offset=offset)
+    return ee_bridge_regression(theta=theta,
+                                X=X, y=y,
+                                model=model,
+                                weights=weights,
+                                penalty=penalty, gamma=1+epsilon, center=center,
+                                offset=offset)
 
 
 def ee_elasticnet_regression(theta, X, y, model, penalty, ratio, epsilon=3.e-3, weights=None, center=0., offset=None):
@@ -718,8 +951,8 @@ def ee_elasticnet_regression(theta, X, y, model, penalty, ratio, epsilon=3.e-3,
 
     Note
     ----
-    As the derivative of the estimating equation for Elastic-Net is not defined, the bread (and sandwich) cannot be
-    used to estimate the variance in all settings.
+    As the derivative of the estimating equation for LASSO is not defined at :math:`\theta=0`, the bread (and sandwich)
+    cannot be used to estimate the variance in all settings.
 
 
     The estimating equation for Elastic-Net linear regression with the approximate L1 penalty is
@@ -1256,7 +1489,7 @@ def ee_additive_regression(theta, X, y, specifications, model, weights=None, off
 #################################################################
 # Utility functions for regression equations
 
-def _prep_inputs_(X, y, theta, penalty=None, center=None, offset=None):
+def _prep_inputs_(X, y, theta, penalty=None, center=None, offset=None, reshape_y=True):
     """Internal use function to simplify variable transformations for regression. This function is used on the inputs
     to ensure they are the proper shapes
 
@@ -1270,13 +1503,19 @@ def _prep_inputs_(X, y, theta, penalty=None, center=None, offset=None):
         Input parameters
     penalty : ndarray, None, optional
         Input penalty term(s)
+    reshape_y : bool, optional
+        Whether to reshape the y array. All regression functions, besides multinomial logit, expect the reshaped
+        array. Therefore, default is True, with only ``ee_mlogit`` modifying this parameter.
 
     Returns
     -------
     transformed parameters
     """
     X = np.asarray(X)                       # Convert to NumPy array
-    y = np.asarray(y)[:, None]              # Convert to NumPy array and ensure correct shape for matrix algebra
+    if reshape_y:                           # Convert to NumPy array
+        y = np.asarray(y)[:, None]          # ... ensure correct shape for matrix algebra
+    else:                                   # ... otherwise
+        y = np.asarray(y)                   # ... keep original shape
     beta = np.asarray(theta)[:, None]       # Convert to NumPy array and ensure correct shape for matrix algebra
 
     # Logic to determine the offset variable if requested
@@ -1302,6 +1541,8 @@ def _model_transform_(model, assert_linear_model=False):
     ----------
     model : str
         Model identifier to calculate the transformation for
+    assert_linear_model : bool, optional
+        Flag to assert whether only a linear model is supported.
 
     Returns
     -------
@@ -1353,11 +1594,13 @@ def _inverse_link_(betax, link):
     elif link == 'probit':
         # py = norm.cdf(betax)                  # Inverse link
         # dpy = norm.pdf(betax)                 # Derivative of inverse link
+        # Distributions compatible with autodiff
         py = standard_normal_cdf(x=betax)       # Inverse link
         dpy = standard_normal_pdf(x=betax)      # Derivative of inverse link
     elif link in ['cauchit', 'cauchy']:
         # py = cauchy.cdf(betax)                # Inverse link
         # dpy = cauchy.pdf(betax)               # Derivative of inverse link
+        # Distributions by-hand for autodiff
         py = (1/np.pi)*np.arctan(betax) + 0.5   # Inverse link
         dpy = 1 / (np.pi*(1 + betax**2))        # Derivative of inverse link (by-hand)
     elif link in ['square_root', 'sqrt']:
diff --git a/delicatessen/estimating_equations/survival.py b/delicatessen/estimating_equations/survival.py
index 33bd94c..f853ac3 100644
--- a/delicatessen/estimating_equations/survival.py
+++ b/delicatessen/estimating_equations/survival.py
@@ -1,3 +1,7 @@
+#####################################################################################################################
+# Estimating functions for survival or time-to-event analyses
+#####################################################################################################################
+
 import numpy as np
 
 from delicatessen.estimating_equations.processing import generate_weights
diff --git a/delicatessen/utilities.py b/delicatessen/utilities.py
index 5a9e48f..829cec0 100644
--- a/delicatessen/utilities.py
+++ b/delicatessen/utilities.py
@@ -5,55 +5,6 @@
 from delicatessen.derivative import PrimalTangentPairs as PTPair
 
 
-def polygamma(n, x):
-    """Polygamma functions. This is a wrapper function of ``scipy.special.polygamma`` meant to enable automatic
-    differentation with ``delicatessen``. When the input is a ``PrimalTangentPairs`` object, then an internal function
-    that implements the polygamma function is called. Otherwise, ``scipy.special.polygamma`` is called for the input
-    object.
-
-    Parameters
-    ----------
-    n : int
-        Order of the derivative of the digamma function
-    x : int, float, ndarray
-        Real valued input
-
-    Returns
-    -------
-    Return type depends on the input type (``PrimalTangentPairs`` will return ``PrimalTangentPairs``, otherwise will
-    return ``ndarray``).
-    """
-    if isinstance(x, np.ndarray):
-        storage = []
-        for xi in x:
-            pgnxi = polygamma(n=n, x=xi)
-            storage.append(pgnxi)
-        return np.array(storage)
-    elif isinstance(x, PTPair):
-        return x.polygamma(n=n)
-    else:
-        return sp.special.polygamma(n=n, x=x)
-
-
-def digamma(z):
-    """Digamma function. This is a wrapper function of ``scipy.special.digamma`` meant to enable automatic
-    differentation with ``delicatessen``. When the input is a ``PrimalTangentPairs`` object, then an internal function
-    that implements the digamma function is called. Otherwise, ``scipy.special.digamma`` is called for the input
-    object.
-
-    Parameters
-    ----------
-    x : int, float, ndarray
-        Real valued input
-
-    Returns
-    -------
-    Return type depends on the input type (``PrimalTangentPairs`` will return ``PrimalTangentPairs``, otherwise will
-    return ``ndarray``).
-    """
-    return polygamma(n=0, x=z)
-
-
 def logit(prob):
     """Logistic transformation of probabilities. Returns log-odds
 
@@ -104,6 +55,55 @@ def identity(value):
     return value
 
 
+def polygamma(n, x):
+    """Polygamma functions. This is a wrapper function of ``scipy.special.polygamma`` meant to enable automatic
+    differentation with ``delicatessen``. When the input is a ``PrimalTangentPairs`` object, then an internal function
+    that implements the polygamma function is called. Otherwise, ``scipy.special.polygamma`` is called for the input
+    object.
+
+    Parameters
+    ----------
+    n : int
+        Order of the derivative of the digamma function
+    x : int, float, ndarray
+        Real valued input
+
+    Returns
+    -------
+    Return type depends on the input type (``PrimalTangentPairs`` will return ``PrimalTangentPairs``, otherwise will
+    return ``ndarray``).
+    """
+    if isinstance(x, np.ndarray):
+        storage = []
+        for xi in x:
+            pgnxi = polygamma(n=n, x=xi)
+            storage.append(pgnxi)
+        return np.array(storage)
+    elif isinstance(x, PTPair):
+        return x.polygamma(n=n)
+    else:
+        return sp.special.polygamma(n=n, x=x)
+
+
+def digamma(z):
+    """Digamma function. This is a wrapper function of ``scipy.special.digamma`` meant to enable automatic
+    differentation with ``delicatessen``. When the input is a ``PrimalTangentPairs`` object, then an internal function
+    that implements the digamma function is called. Otherwise, ``scipy.special.digamma`` is called for the input
+    object.
+
+    Parameters
+    ----------
+    x : int, float, ndarray
+        Real valued input
+
+    Returns
+    -------
+    Return type depends on the input type (``PrimalTangentPairs`` will return ``PrimalTangentPairs``, otherwise will
+    return ``ndarray``).
+    """
+    return polygamma(n=0, x=z)
+
+
 def standard_normal_cdf(x):
     """Cumulative distribution function for the standard normal distribution. This is a wrapper function of
     ``scipy.stats.norm.cdf`` meant to enable automatic differentation with ``delicatessen``. When the input is a
@@ -430,7 +430,7 @@ def regression_predictions(X, theta, covariance, offset=None, alpha=0.05):
     return np.vstack([yhat, yhat_var, lower_ci, upper_ci]).T
 
 
-def spline(variable, knots, power=3, restricted=True):
+def spline(variable, knots, power=3, restricted=True, normalized=False):
     r"""Generate generic polynomial spline terms for a given NumPy array and pre-specified knots. Default is restricted
     cubic splines but unrestricted splines to different polynomial terms can also be specified.
 
@@ -448,7 +448,9 @@ def spline(variable, knots, power=3, restricted=True):
 
         r_k(X) = I(X > k) \left\{ X - k \right\}^a - s_K(X)
 
-    where :math:`K` is largest knot value.
+    where :math:`K` is largest knot value. Splines are normalized by the upper knot minus the lower knot to the
+    corresponding power. Normalizing the splines can be helpful for the root-finding procedure, but does change the
+    interpretation of the corresponding coefficients.
 
     Parameters
     ----------
@@ -463,6 +465,10 @@ def spline(variable, knots, power=3, restricted=True):
         Whether to generate restricted or unrestricted splines. Default is True, which corresponds to restricted
         splines. Restricted splines return one less column than the number of knots, whereas unrestricted splines
         return the same number of columns as knots
+    normalized : bool, optional
+        Whether to normalize, or divide, the spline terms by the difference between the upper and lower knots. Default
+        is ``False``, which corresponds to unnormalized splines. The default will update to ``True`` in the v3.0
+        release.
 
     Returns
     -------
@@ -481,12 +487,16 @@ def spline(variable, knots, power=3, restricted=True):
 
     >>> x = np.random.normal(size=200)
 
-    A restricted cubic spline with 3 knots (at -1, 0, 1) can be generated using the following function call
+    A restricted quadratic spline with 3 knots (at -1, 0, 1) can be generated using the following function call
 
-    >>> spline(variable=x, knots=[-1, 0, 1], power=3, restricted=True)
+    >>> spline(variable=x, knots=[-1, 0, 1], power=2, restricted=True)
 
     This function will return a 2 by 200 array here. Other knot specifications, other powers, and unrestricted splines
     can also be generated by updating the corresponding arguments.
+
+    References
+    ----------
+    Mulla ZD (2007). Spline regression in clinical research. *West Indian Med J*, 56(1), 77.
     """
     # Processing input and output arrays
     knots = sorted(knots)                           # Sorting the order of the knots
@@ -494,6 +504,15 @@ def spline(variable, knots, power=3, restricted=True):
     x = np.asarray(variable)                        # Converting input variable to another array
     spline_terms = np.empty((x.shape[0], n_cols))   # Creating the output spline array
 
+    # Determining normalization
+    if normalized:                                     # Rescale spline terms so less extreme ranges (helps root-finder)
+        if n_cols == 1:                                # When only a single knot
+            divisor = knots[0]**power                  # ... scale by knot to that power
+        else:                                          # Otherwise
+            divisor = (knots[-1] - knots[0])**power    # ... range of the knots to that power
+    else:                                              # If not rescaling (only option up to v2.0)
+        divisor = 1                                    # ... divisor is 1
+
     # Generating each spline with it's corresponding term
     for i in range(n_cols):
         spline_terms[:, i] = np.where(x > knots[i], (x - knots[i])**power, 0)
@@ -501,12 +520,12 @@ def spline(variable, knots, power=3, restricted=True):
 
     # Logic for unrestricted and restricted splines
     if restricted is False:
-        return spline_terms
+        return spline_terms / divisor
     else:
         for i in range(n_cols - 1):
             spline_terms[:, i] = np.where(x > knots[i], spline_terms[:, i] - spline_terms[:, -1], 0)
             spline_terms[:, i] = np.where(np.isnan(x), np.nan, spline_terms[:, i])
-        return spline_terms[:, :-1]
+        return spline_terms[:, :-1] / divisor
 
 
 def additive_design_matrix(X, specifications, return_penalty=False):
@@ -535,6 +554,8 @@ def additive_design_matrix(X, specifications, return_penalty=False):
             splines.
         * penalty (float): penalty term (:math:`\lambda`) applied to each corresponding spline basis term. Default is 0,
             which applies no penalty to the spline basis terms.
+        * normalized (bool): whether to normalize the spline terms. Default is ``False``, with a default change coming
+            with v3.0 release.
     return_penalty : bool, optional
         Whether the list of the corresponding penalty terms should also be returned. This functionality is used
         internally to create the list of penalty terms to provide the Ridge regression model, where only the spline
@@ -616,10 +637,11 @@ def generate_spline(variable, specification):
         ndarray :
             Returns the object returned by the spline (the basis matrix of the spline terms for the column)
         """
-        return spline(variable=variable,                      # Pass variable to spline function
-                      knots=specification["knots"],           # ... with knot locations
-                      power=specification["power"],           # ... to the power
-                      restricted=specification["natural"])    # ... and whether to restrict
+        return spline(variable=variable,                        # Pass variable to spline function
+                      knots=specification["knots"],             # ... with knot locations
+                      power=specification["power"],             # ... to the power
+                      restricted=specification["natural"],      # ... whether to restrict
+                      normalized=specification["normalized"])   # ... and whether to normalize by knot range
 
     def generate_default_spline_parameters(specification):
         """Internal function to process the input specification dictionary of spline parameters. Namely, ensure that
@@ -640,10 +662,11 @@ def generate_default_spline_parameters(specification):
         defaults = {"knots": None,                                    # Default values (knots must be provided)
                     "natural": True,                                  # ... default to restricted splines
                     "power": 3,                                       # ... default to cubic splines
-                    "penalty": 0}                                     # ... default to NO penalty on spline terms
+                    "penalty": 0,                                     # ... default to NO penalty on spline terms
+                    "normalized": False}                              # ... default to NO normalized splines
 
         # Checking the keys in the input dictionary against the expected keys
-        expected_keys = ["knots", "natural", "power", "penalty", ]    # List of keys expected to occur in the dict
+        expected_keys = ["knots", "natural", "power", "penalty", "normalized"]  # List of keys expected to occur
         extra_keys = [param for param in keys if param not in expected_keys]
         if len(extra_keys) != 0:
             warnings.warn("The following keys were found in the specification: " + str(extra_keys)
@@ -653,15 +676,10 @@ def generate_default_spline_parameters(specification):
         # Managing knot keyword
         if "knots" not in keys:
             raise ValueError("`knots` must be specified.")
-        # Managing keyword for natural / restricted splines
-        if "natural" not in keys:
-            specification["natural"] = defaults["natural"]
-        # Managing keyword for power to raise splines to
-        if "power" not in keys:
-            specification["power"] = defaults["power"]
-        # Managing keyword for penalty to apply to splines
-        if "penalty" not in keys:
-            specification["penalty"] = defaults["penalty"]
+        # Managing spline keywords with defaults
+        for kw in ["natural", "power", "penalty", "normalized"]:
+            if kw not in keys:
+                specification[kw] = defaults[kw]
 
         # Returning processed spline parameters dictionary
         return specification
diff --git a/delicatessen/version.py b/delicatessen/version.py
index f2dc0e4..ab02866 100644
--- a/delicatessen/version.py
+++ b/delicatessen/version.py
@@ -1,1 +1,1 @@
-__version__ = "2.0"
+__version__ = "2.1"
diff --git a/docs/Examples/Cole-AJE-2023.ipynb b/docs/Examples/Cole-AJE-2023.ipynb
new file mode 100644
index 0000000..4d76882
--- /dev/null
+++ b/docs/Examples/Cole-AJE-2023.ipynb
@@ -0,0 +1,415 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "6f850c92",
+   "metadata": {},
+   "source": [
+    "# Cole et al. (2023): Fusion Designs\n",
+    "\n",
+    "The following is a replication of the cases described in Cole et al. (2023) and the rejoinder. The original paper considered fusion study designs, whereby multiple data sources are used together to address a single question. Examples are provided in the context of transporting a proportion, measurement error, and the joint occurrence of measurement error and transporting. In that paper, M-estimators are proposed as a general solution to estimation in fusion designs. Importantly, the empirical sandwich variance estimator allows for uncertainty estimation in this setting, unlike standard methods or approximations (like the GEE trick for inverse probability weighting). Further, these procedures are less computationally demanding than competing methods that do provide appropriate inference (e.g., bootstrapping, Monte Carlo methods). \n",
+    "\n",
+    "Here, we recreate the cases described in the paper. For finer details, see the original publications.\n",
+    "\n",
+    "## Setup"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "id": "75b0db4f",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Versions\n",
+      "NumPy:         1.25.2\n",
+      "pandas:        1.4.1\n",
+      "Delicatessen:  2.1\n"
+     ]
+    }
+   ],
+   "source": [
+    "import numpy as np\n",
+    "import pandas as pd\n",
+    "\n",
+    "import delicatessen\n",
+    "from delicatessen import MEstimator\n",
+    "from delicatessen.estimating_equations import ee_regression, ee_rogan_gladen\n",
+    "from delicatessen.utilities import inverse_logit\n",
+    "\n",
+    "print(\"Versions\")\n",
+    "print(\"NumPy:        \", np.__version__)\n",
+    "print(\"pandas:       \", pd.__version__)\n",
+    "print(\"Delicatessen: \", delicatessen.__version__)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "deaf49e4",
+   "metadata": {},
+   "source": [
+    "## Case 1: Transporting the proportion\n",
+    "\n",
+    "The goal is to estimate $\\Pr(Y=1)$ in the target population. However, we only have the outcome measured in a sample of a secondary population ($S=1$). To estimate the proportion in the target population, we will transport from the secondary population to the target population conditional on a covariate, $W$. For estimation, we use inverse odds of sampling weights.\n",
+    "\n",
+    "First, we build the data set described in Cole et al."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "id": "25c89f4f",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "d = pd.DataFrame()\n",
+    "d['Y'] = [0, 1, 0, 1] + [np.nan, np.nan]\n",
+    "d['W'] = [0, 0, 1, 1] + [0, 1]\n",
+    "d['n'] = [266, 67, 400, 267] + [333, 167]\n",
+    "d['S'] = [0, 0, 0, 0] + [1, 1]\n",
+    "\n",
+    "# Expanding data set out \n",
+    "d = pd.DataFrame(np.repeat(d.values, d['n'], axis=0), columns=d.columns)\n",
+    "d['C'] = 1\n",
+    "\n",
+    "# Setting up for delicatessen\n",
+    "y_no_nan = np.asarray(d['Y'].fillna(-999))\n",
+    "s = np.asarray(d['S'])\n",
+    "W = np.asarray(d[['C', 'W']])"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "dea2921e",
+   "metadata": {},
+   "source": [
+    "For estimation, we stack the naive proportion, inverse odds weighted proportion, and sampling models together. "
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "id": "0a6d6de5",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def psi(theta):\n",
+    "    # Subsetting the input parameters\n",
+    "    mu_w = theta[0]    # Weighted proportion\n",
+    "    mu_n = theta[1]    # Naive proportion\n",
+    "    beta = theta[2:]   # Sampling model parameters\n",
+    "    \n",
+    "    # Sampling model for transporting\n",
+    "    ee_sm = ee_regression(beta, \n",
+    "                          X=W, y=d['S'],\n",
+    "                          model='logistic')\n",
+    "    \n",
+    "    # Calculating inverse odds of sampling weights\n",
+    "    pi_s = inverse_logit(np.dot(W, beta))\n",
+    "    iosw = (1-s) * pi_s / (1-pi_s)\n",
+    "    \n",
+    "    # Weighted mean\n",
+    "    ee_wprop = iosw * (y_no_nan - mu_w)\n",
+    "    \n",
+    "    # Naive mean\n",
+    "    ee_nprop = (1-s) * (y_no_nan - mu_n)\n",
+    "    \n",
+    "    # Returning the stacked estimating equations\n",
+    "    return np.vstack([ee_wprop, ee_nprop, ee_sm])"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "id": "cb3d81ae",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "estr = MEstimator(psi, init=[0.5, 0.5, 0., 0.])\n",
+    "estr.estimate()\n",
+    "ci = estr.confidence_intervals()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 5,
+   "id": "4729e9c4",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Weighted proportion: 0.27, 95% CI: 0.24, 0.30\n",
+      "Naive proportion:    0.33, 95% CI: 0.30, 0.36\n",
+      "Model coefficients:  -0.000, -1.385\n"
+     ]
+    }
+   ],
+   "source": [
+    "fmt = '{:.2f}, 95% CI: {:.2f}, {:.2f}'\n",
+    "print(\"Weighted proportion:\", fmt.format(estr.theta[0], ci[0, 0], ci[0, 1]))\n",
+    "print(\"Naive proportion:   \", fmt.format(estr.theta[1], ci[1, 0], ci[1, 1]))\n",
+    "fmt = \"{:.3f}, {:.3f}\"\n",
+    "print(\"Model coefficients: \", fmt.format(estr.theta[2], estr.theta[3]))"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "00204705",
+   "metadata": {},
+   "source": [
+    "Note that this example differs in number from that reported in Cole et al. Since the corresponding data was not provided in full in the publication, we instead adapt the data from case three here. Regardless, the estimating equation setup is the same between examples.\n",
+    "\n",
+    "## Case 2: Estimating a Misclassified Proportion\n",
+    "\n",
+    "The goal is to use external sensitivity and specificity data to correct for measurement error in the main sample. We do this by using the Rogan-Gladen correction\n",
+    "$$ \\hat{\\mu} = \\frac{\\hat{\\mu}^* + \\widehat{Sp} - 1}{\\widehat{Se}+ \\widehat{Sp} - 1} $$\n",
+    "where $\\mu$ is the corrected mean, $\\mu^*$ is the mismeasured mean, $Se$ is the sensitivity, and $Sp$ is the specificity. Hats indicate estimated quantities. \n",
+    "\n",
+    "First, we build the data set described in Cole et al."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 6,
+   "id": "3d7e564b",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Compact data frame expression of data\n",
+    "d = pd.DataFrame()\n",
+    "d['R'] = [1, 1, 0, 0, 0, 0]\n",
+    "d['Y'] = [np.nan, np.nan, 1, 1, 0, 0]\n",
+    "d['Y_star'] = [1, 0, 1, 0, 1, 0]\n",
+    "d['n'] = [680, 270, 204, 38, 18, 71]\n",
+    "\n",
+    "# Expanding data set out \n",
+    "d = pd.DataFrame(np.repeat(d.values, d['n'], axis=0),\n",
+    "                 columns=d.columns)\n",
+    "d = d[['R', 'Y_star', 'Y']].copy()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "bc742a54",
+   "metadata": {},
+   "source": [
+    "For estimation, we use the built-in estimating equation for the Rogan-Gladen correction, `ee_rogan_gladen`"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 7,
+   "id": "8f811703",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def psi(theta):\n",
+    "    return ee_rogan_gladen(theta=theta, y=d['Y'],\n",
+    "                           y_star=d['Y_star'], r=d['R'])"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 8,
+   "id": "5edd5380",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "estr = MEstimator(psi, init=[0.5, 0.5, .75, .75])\n",
+    "estr.estimate()\n",
+    "ci = estr.confidence_intervals()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 9,
+   "id": "f791d522",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Corrected proportion: 0.80, 95% CI: 0.72, 0.88\n",
+      "Naive proportion:     0.72, 95% CI: 0.69, 0.74\n",
+      "Sensitivity:          0.84, 95% CI: 0.80, 0.89\n",
+      "Specificity:          0.80, 95% CI: 0.71, 0.88\n"
+     ]
+    }
+   ],
+   "source": [
+    "fmt = '{:.2f}, 95% CI: {:.2f}, {:.2f}'\n",
+    "print(\"Corrected proportion:\", fmt.format(estr.theta[0], ci[0, 0], ci[0, 1]))\n",
+    "print(\"Naive proportion:    \", fmt.format(estr.theta[1], ci[1, 0], ci[1, 1]))\n",
+    "print(\"Sensitivity:         \", fmt.format(estr.theta[2], ci[2, 0], ci[2, 1]))\n",
+    "print(\"Specificity:         \", fmt.format(estr.theta[3], ci[3, 0], ci[3, 1]))"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "3e265cd7",
+   "metadata": {},
+   "source": [
+    "These results match those provided in Cole et al.\n",
+    "\n",
+    "## Case 3: Estimating a Misclassified and Transported Proportion\n",
+    "\n",
+    "In the rejoinder, Cole et al. combine the previous examples. Specifically, we now transport the main sample and then correct for measurement error afterwards. \n",
+    "\n",
+    "First, we build the data described"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 10,
+   "id": "216d9211",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Compact data frame expression of data\n",
+    "d = pd.DataFrame()\n",
+    "d['Y_star'] = [0, 1, 0, 1] + [np.nan, np.nan] + [1, 0, 1, 0]\n",
+    "d['Y'] = [np.nan, np.nan, np.nan, np.nan] + [np.nan, np.nan] + [1, 1, 0, 0]\n",
+    "d['W'] = [0, 0, 1, 1] + [0, 1] + [np.nan, np.nan, np.nan, np.nan]\n",
+    "d['n'] = [266, 67, 400, 267] + [333, 167] + [180, 20, 60, 240]\n",
+    "d['S'] = [1, 1, 1, 1] + [2, 2] + [3, 3, 3, 3]\n",
+    "\n",
+    "# Expanding data set out \n",
+    "d = pd.DataFrame(np.repeat(d.values, d['n'], axis=0), columns=d.columns)\n",
+    "d['C'] = 1\n",
+    "\n",
+    "# Some extra data processing \n",
+    "y_no_nan = np.asarray(d['Y'].fillna(-1))\n",
+    "ystar_no_nan = np.asarray(d['Y_star'].fillna(-1))\n",
+    "w_no_nan = np.asarray(d[['C', 'W']].fillna(-1))\n",
+    "s1 = np.where(d['S'] == 1, 1, 0)\n",
+    "s2 = np.where(d['S'] == 2, 1, 0)\n",
+    "s3 = np.where(d['S'] == 3, 1, 0)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 11,
+   "id": "bfc960d3",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def psi(theta):\n",
+    "    # Subsetting the input parameters\n",
+    "    param = theta[:4]    # Measurement error parameters\n",
+    "    beta = theta[4:]     # Sampling model parameters\n",
+    "\n",
+    "    # Sampling model for transporting\n",
+    "    ee_sm = ee_regression(beta, \n",
+    "                          X=w_no_nan, \n",
+    "                          y=s2,\n",
+    "                          model='logistic')\n",
+    "    ee_sm = ee_sm * (1 - s3)   # Only S=1 or S=2 contribute\n",
+    "    \n",
+    "    # Calculating inverse odds of sampling weights\n",
+    "    pi_s = inverse_logit(np.dot(w_no_nan, beta))\n",
+    "    # Note: iosw is the odds weight if S=1, zero if S=2 and 1 if S=3.\n",
+    "    #       So S=2 don't contribute to measurement error model, the\n",
+    "    #       naive mean among S=1 is reweighted appropriately, and S=3\n",
+    "    #       observations all contribute equally (we can't estimate\n",
+    "    #       weights for them since W was not measured)\n",
+    "    iosw = s1 * pi_s / (1-pi_s) + s3\n",
+    "\n",
+    "    # Rogan-Gladen correction\n",
+    "    ee_rg = ee_rogan_gladen(param,\n",
+    "                            y=y_no_nan,\n",
+    "                            y_star=ystar_no_nan,\n",
+    "                            r=s1,\n",
+    "                            weights=iosw)\n",
+    "    ee_rg = ee_rg * (1 - s2)  # Only S=1 or S=3 contribute\n",
+    "    \n",
+    "    # Returning the stacked estimating equations\n",
+    "    return np.vstack([ee_rg, ee_sm])"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 12,
+   "id": "b907c38a",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "estr = MEstimator(psi, init=[0.5, 0.5, .75, .75, 0., 0.])\n",
+    "estr.estimate(solver='lm')\n",
+    "se = np.diag(estr.variance)**0.5"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 13,
+   "id": "503cef27",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Corrected weighted proportion: 0.097 (SE=0.038)\n",
+      "Naive weighted proportion:     0.268 (SE=0.016)\n",
+      "Sensitivity:                   0.900 (SE=0.021)\n",
+      "Specificity:                   0.800 (SE=0.023)\n",
+      "Sampling model coefficients:   -0.000, -1.385\n"
+     ]
+    }
+   ],
+   "source": [
+    "fmt = '{:.3f} (SE={:.3f})'\n",
+    "print(\"Corrected weighted proportion:\", fmt.format(estr.theta[0], se[0]))\n",
+    "print(\"Naive weighted proportion:    \", fmt.format(estr.theta[1], se[1]))\n",
+    "print(\"Sensitivity:                  \", fmt.format(estr.theta[2], se[2]))\n",
+    "print(\"Specificity:                  \", fmt.format(estr.theta[3], se[3]))\n",
+    "fmt = \"{:.3f}, {:.3f}\"\n",
+    "print(\"Sampling model coefficients:  \", fmt.format(estr.theta[4], estr.theta[5]))"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "976ee485",
+   "metadata": {},
+   "source": [
+    "These results match those reported in the rejoinder. Note that the coefficients for the sampling model have a negative sign in the front since we are modeling $\\Pr(S=1 | W)$, but the paper models $\\Pr(S=0 | W)$ so there is no negative sign.\n",
+    "\n",
+    "### Conclusion\n",
+    "\n",
+    "We replicated the cases from Cole et al. (2023) to showcase the basics of M-estimators for fusion designs and how they are implemented in `delicatessen`.\n",
+    "\n",
+    "### References\n",
+    "\n",
+    "Cole SR, Edwards JK, Breskin A, Rosin S, Zivich PN, Shook-Sa BE, & Hudgens MG. (2023). \"Illustration of 2 Fusion Designs and Estimators\". *American Journal of Epidemiology*, 192(3), 467-474.\n",
+    "\n",
+    "Cole SR, Edwards JK, Breskin A, Rosin S, Zivich PN, Shook-Sa BE, & Hudgens MG. (2023). \"Rejoinder: Combining Information with Fusion Designs, and Roses by Other Names\". *American Journal of Epidemiology*, kwad084."
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3 (ipykernel)",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.9.4"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/docs/Examples/Ross-IJE-2024.ipynb b/docs/Examples/Ross-IJE-2024.ipynb
new file mode 100644
index 0000000..8fe7f34
--- /dev/null
+++ b/docs/Examples/Ross-IJE-2024.ipynb
@@ -0,0 +1,758 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "33646d20",
+   "metadata": {},
+   "source": [
+    "# Ross et al. (2024): Introduction to M-estimation\n",
+    "\n",
+    "The following is a replication of the cases described in Ross et al. (2024). The original paper provides a tutorial on M-estimation and provides several introductory examples. Examples are provided in the context of regression, standardization, and measurement error. Here, we recreate the cases described in the paper. For finer details, see the original publication.\n",
+    "\n",
+    "## Setup"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "id": "127d7517",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Versions\n",
+      "NumPy:         1.25.2\n",
+      "pandas:        1.4.1\n",
+      "Delicatessen:  2.1\n"
+     ]
+    }
+   ],
+   "source": [
+    "import numpy as np\n",
+    "import pandas as pd\n",
+    "\n",
+    "import delicatessen\n",
+    "from delicatessen import MEstimator\n",
+    "from delicatessen.estimating_equations import ee_regression, ee_rogan_gladen\n",
+    "from delicatessen.utilities import inverse_logit\n",
+    "\n",
+    "print(\"Versions\")\n",
+    "print(\"NumPy:        \", np.__version__)\n",
+    "print(\"pandas:       \", pd.__version__)\n",
+    "print(\"Delicatessen: \", delicatessen.__version__)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "39733cb2",
+   "metadata": {},
+   "source": [
+    "The following data is used for the first and second cases."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "id": "47ddfd6c",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# From Table 1\n",
+    "d = pd.DataFrame()\n",
+    "d['X'] = [0, 0, 0, 0, 1, 1, 1, 1]            # X values\n",
+    "d['W'] = [0, 0, 1, 1, 0, 0, 1, 1]            # W values\n",
+    "d['Y'] = [0, 1, 0, 1, 0, 1, 0, 1]            # Y values\n",
+    "d['n'] = [496, 74, 113, 25, 85, 15, 15, 3]   # Counts\n",
+    "d['intercept'] = 1                           # Intercept term (always 1)\n",
+    "\n",
+    "# Expanding rows by n\n",
+    "d = pd.DataFrame(np.repeat(d.values,         # Converting tabled data\n",
+    "                           d['n'], axis=0),  # ... by replicating counts\n",
+    "                 columns=d.columns)          # ... into rows for each X,W,Y\n",
+    "d = d[['intercept', 'X', 'W', 'Y']].copy()   # Dropping extra rows"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "4324be23",
+   "metadata": {},
+   "source": [
+    "## Example 1: Logistic Regression\n",
+    "\n",
+    "For the first example, we fit a logistic regression model for the variable $Y$ given $X,W$. "
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "id": "13a73472",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Extracting arrays for easier coding later on\n",
+    "X = np.asarray(d[['intercept', 'X', 'W']])   # Design matrix for regression\n",
+    "y = np.asarray(d['Y'])                       # Outcome in regression"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "08cfae8f",
+   "metadata": {},
+   "source": [
+    "For estimation, we can use the built-in function for regression"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "id": "046f1c7a",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def psi(theta):\n",
+    "    return ee_regression(theta=theta, \n",
+    "                         y=y, X=X, \n",
+    "                         model='logistic')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 5,
+   "id": "444f32ef",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "estr = MEstimator(psi, init=[0, 0, 0,])\n",
+    "estr.estimate()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 6,
+   "id": "c8a01022",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/html": [
+       "<div>\n",
+       "<style scoped>\n",
+       "    .dataframe tbody tr th:only-of-type {\n",
+       "        vertical-align: middle;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe tbody tr th {\n",
+       "        vertical-align: top;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe thead th {\n",
+       "        text-align: right;\n",
+       "    }\n",
+       "</style>\n",
+       "<table border=\"1\" class=\"dataframe\">\n",
+       "  <thead>\n",
+       "    <tr style=\"text-align: right;\">\n",
+       "      <th></th>\n",
+       "      <th>Param</th>\n",
+       "      <th>Coef</th>\n",
+       "      <th>LCL</th>\n",
+       "      <th>UCL</th>\n",
+       "    </tr>\n",
+       "  </thead>\n",
+       "  <tbody>\n",
+       "    <tr>\n",
+       "      <th>0</th>\n",
+       "      <td>beta_0</td>\n",
+       "      <td>-1.89</td>\n",
+       "      <td>-2.13</td>\n",
+       "      <td>-1.66</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>1</th>\n",
+       "      <td>beta_1</td>\n",
+       "      <td>0.12</td>\n",
+       "      <td>-0.43</td>\n",
+       "      <td>0.67</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>2</th>\n",
+       "      <td>beta_2</td>\n",
+       "      <td>0.36</td>\n",
+       "      <td>-0.11</td>\n",
+       "      <td>0.83</td>\n",
+       "    </tr>\n",
+       "  </tbody>\n",
+       "</table>\n",
+       "</div>"
+      ],
+      "text/plain": [
+       "    Param  Coef   LCL   UCL\n",
+       "0  beta_0 -1.89 -2.13 -1.66\n",
+       "1  beta_1  0.12 -0.43  0.67\n",
+       "2  beta_2  0.36 -0.11  0.83"
+      ]
+     },
+     "execution_count": 6,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "# Formatting results into a nice table\n",
+    "result = pd.DataFrame()\n",
+    "result['Param'] = ['beta_0', 'beta_1', 'beta_2']\n",
+    "result['Coef'] = estr.theta\n",
+    "ci = estr.confidence_intervals()\n",
+    "result['LCL'] = ci[:, 0]\n",
+    "result['UCL'] = ci[:, 1]\n",
+    "result.round(2)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "a8ea3739",
+   "metadata": {},
+   "source": [
+    "These results match those reported in the paper. They also match the results if you were to fit this using `statsmodels` GLM instead.\n",
+    "\n",
+    "## Example 2: Estimating the marginal risk difference\n",
+    "\n",
+    "In this example, we build on the prior example. Using the logistic model, we can generate predictions and use those predictions to estimate the marginal risk difference by $X$ via g-computation. Alternatively, we can use a logistic model to estimate propensity score and then estimate the marginal risk difference with inverse probability weighting. \n",
+    "\n",
+    "First, let's apply g-computation"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 7,
+   "id": "6b493f2a",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Copies of data with policies applied\n",
+    "d1 = d.copy()\n",
+    "d1['X'] = 1\n",
+    "X1 = np.asarray(d1[['intercept', 'X', 'W']])\n",
+    "d0 = d.copy()\n",
+    "d0['X'] = 0\n",
+    "X0 = np.asarray(d0[['intercept', 'X', 'W']])"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 8,
+   "id": "e9fce370",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def psi(theta):\n",
+    "    # Dividing parameters into corresponding parts and labels from slides\n",
+    "    beta = theta[0:3]              # Logistic model coefficients\n",
+    "    mu0, mu1 = theta[3], theta[4]  # Causal risks\n",
+    "    delta1 = theta[5]              # Causal contrast\n",
+    "\n",
+    "    # Logistic regression model for outcome\n",
+    "    ee_logit = ee_regression(theta=beta,\n",
+    "                             y=y, X=X,\n",
+    "                             model='logistic')\n",
+    "\n",
+    "    # Transforming logistic model coefficients into causal parameters\n",
+    "    y0_hat = inverse_logit(np.dot(X0, beta))  # Prediction under a=0\n",
+    "    y1_hat = inverse_logit(np.dot(X1, beta))  # Prediction under a=1\n",
+    "\n",
+    "    # Estimating function for causal risk under a=1\n",
+    "    ee_r1 = y1_hat - mu1             # Simple mean\n",
+    "\n",
+    "    # Estimating function for causal risk under a=0\n",
+    "    ee_r0 = y0_hat - mu0             # Simple mean\n",
+    "    \n",
+    "    # Estimating function for causal risk difference\n",
+    "    ee_rd = np.ones(d.shape[0])*((mu1 - mu0) - delta1)\n",
+    "\n",
+    "    # Returning stacked estimating functions in order of parameters\n",
+    "    return np.vstack([ee_logit,   # EF of logistic model\n",
+    "                      ee_r0,      # EF of causal risk a=0\n",
+    "                      ee_r1,      # EF of causal risk a=1\n",
+    "                      ee_rd])     # EF of causal contrast"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 9,
+   "id": "0ea2e8bb",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "estr = MEstimator(psi, init=[0, 0, 0, 0.5, 0.5, 0])\n",
+    "estr.estimate()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 10,
+   "id": "3c667d96",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/html": [
+       "<div>\n",
+       "<style scoped>\n",
+       "    .dataframe tbody tr th:only-of-type {\n",
+       "        vertical-align: middle;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe tbody tr th {\n",
+       "        vertical-align: top;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe thead th {\n",
+       "        text-align: right;\n",
+       "    }\n",
+       "</style>\n",
+       "<table border=\"1\" class=\"dataframe\">\n",
+       "  <thead>\n",
+       "    <tr style=\"text-align: right;\">\n",
+       "      <th></th>\n",
+       "      <th>Param</th>\n",
+       "      <th>Coef</th>\n",
+       "      <th>LCL</th>\n",
+       "      <th>UCL</th>\n",
+       "    </tr>\n",
+       "  </thead>\n",
+       "  <tbody>\n",
+       "    <tr>\n",
+       "      <th>0</th>\n",
+       "      <td>beta_0</td>\n",
+       "      <td>-1.89</td>\n",
+       "      <td>-2.13</td>\n",
+       "      <td>-1.66</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>1</th>\n",
+       "      <td>beta_1</td>\n",
+       "      <td>0.12</td>\n",
+       "      <td>-0.43</td>\n",
+       "      <td>0.67</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>2</th>\n",
+       "      <td>beta_2</td>\n",
+       "      <td>0.36</td>\n",
+       "      <td>-0.11</td>\n",
+       "      <td>0.83</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>3</th>\n",
+       "      <td>mu_0</td>\n",
+       "      <td>0.14</td>\n",
+       "      <td>0.11</td>\n",
+       "      <td>0.17</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>4</th>\n",
+       "      <td>mu_1</td>\n",
+       "      <td>0.15</td>\n",
+       "      <td>0.09</td>\n",
+       "      <td>0.22</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>5</th>\n",
+       "      <td>delta</td>\n",
+       "      <td>0.01</td>\n",
+       "      <td>-0.06</td>\n",
+       "      <td>0.09</td>\n",
+       "    </tr>\n",
+       "  </tbody>\n",
+       "</table>\n",
+       "</div>"
+      ],
+      "text/plain": [
+       "    Param  Coef   LCL   UCL\n",
+       "0  beta_0 -1.89 -2.13 -1.66\n",
+       "1  beta_1  0.12 -0.43  0.67\n",
+       "2  beta_2  0.36 -0.11  0.83\n",
+       "3    mu_0  0.14  0.11  0.17\n",
+       "4    mu_1  0.15  0.09  0.22\n",
+       "5   delta  0.01 -0.06  0.09"
+      ]
+     },
+     "execution_count": 10,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "# Formatting results into a nice table\n",
+    "result = pd.DataFrame()\n",
+    "result['Param'] = ['beta_0', 'beta_1', 'beta_2', 'mu_0', 'mu_1', 'delta']\n",
+    "result['Coef'] = estr.theta\n",
+    "ci = estr.confidence_intervals()\n",
+    "result['LCL'] = ci[:, 0]\n",
+    "result['UCL'] = ci[:, 1]\n",
+    "result.round(2)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "86fe06a6",
+   "metadata": {},
+   "source": [
+    "These results match those reported in the publication (some differences in rounding by the chosen root-finding algorithm).\n",
+    "\n",
+    "Now consider estimating the marginal risk difference using inverse probability weighting. The following code implements this estimator by-hand"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 11,
+   "id": "12e678a7",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "a = np.asarray(d['X'])\n",
+    "W = np.asarray(d[['intercept', 'W']])"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 12,
+   "id": "e2906199",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def psi(theta):\n",
+    "    # Dividing parameters into corresponding parts and labels from slides\n",
+    "    alpha = theta[0:2]              # Logistic model coefficients\n",
+    "    mu0, mu1 = theta[2], theta[3]   # Causal risks\n",
+    "    delta1 = theta[4]               # Causal contrast\n",
+    "\n",
+    "    # Logistic regression model for propensity score\n",
+    "    ee_logit = ee_regression(theta=alpha,       # Regression model\n",
+    "                             y=a,               # ... for exposure\n",
+    "                             X=W,               # ... given confounders\n",
+    "                             model='logistic')  # ... logistic model\n",
+    "\n",
+    "    # Transforming logistic model coefficients into causal parameters\n",
+    "    pscore = inverse_logit(np.dot(W, alpha))    # Propensity score\n",
+    "    wt = d['X']/pscore + (1-d['X'])/(1-pscore)  # Corresponding weights\n",
+    "\n",
+    "    # Estimating function for causal risk under a=1\n",
+    "    ee_r1 = d['X']*d['Y']*wt - mu1         # Weighted conditional mean\n",
+    "    \n",
+    "    # Estimating function for causal risk under a=0\n",
+    "    ee_r0 = (1-d['X'])*d['Y']*wt - mu0     # Weighted conditional mean\n",
+    "    \n",
+    "    # Estimating function for causal risk difference\n",
+    "    ee_rd = np.ones(d.shape[0])*((mu1 - mu0) - delta1)\n",
+    "\n",
+    "    # Returning stacked estimating functions in order of parameters\n",
+    "    return np.vstack([ee_logit,   # EF of logistic model\n",
+    "                      ee_r0,      # EF of causal risk a=0\n",
+    "                      ee_r1,      # EF of causal risk a=1\n",
+    "                      ee_rd])     # EF of causal contrast"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 13,
+   "id": "95c791b2",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "estr = MEstimator(psi, init=[0, 0, 0.5, 0.5, 0])\n",
+    "estr.estimate()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 14,
+   "id": "e53cb2ca",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/html": [
+       "<div>\n",
+       "<style scoped>\n",
+       "    .dataframe tbody tr th:only-of-type {\n",
+       "        vertical-align: middle;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe tbody tr th {\n",
+       "        vertical-align: top;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe thead th {\n",
+       "        text-align: right;\n",
+       "    }\n",
+       "</style>\n",
+       "<table border=\"1\" class=\"dataframe\">\n",
+       "  <thead>\n",
+       "    <tr style=\"text-align: right;\">\n",
+       "      <th></th>\n",
+       "      <th>Param</th>\n",
+       "      <th>Coef</th>\n",
+       "      <th>LCL</th>\n",
+       "      <th>UCL</th>\n",
+       "    </tr>\n",
+       "  </thead>\n",
+       "  <tbody>\n",
+       "    <tr>\n",
+       "      <th>0</th>\n",
+       "      <td>alpha_0</td>\n",
+       "      <td>-1.74</td>\n",
+       "      <td>-1.95</td>\n",
+       "      <td>-1.53</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>1</th>\n",
+       "      <td>alpha_1</td>\n",
+       "      <td>-0.30</td>\n",
+       "      <td>-0.83</td>\n",
+       "      <td>0.24</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>2</th>\n",
+       "      <td>mu_0</td>\n",
+       "      <td>0.14</td>\n",
+       "      <td>0.11</td>\n",
+       "      <td>0.17</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>3</th>\n",
+       "      <td>mu_1</td>\n",
+       "      <td>0.15</td>\n",
+       "      <td>0.09</td>\n",
+       "      <td>0.22</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>4</th>\n",
+       "      <td>delta</td>\n",
+       "      <td>0.01</td>\n",
+       "      <td>-0.06</td>\n",
+       "      <td>0.08</td>\n",
+       "    </tr>\n",
+       "  </tbody>\n",
+       "</table>\n",
+       "</div>"
+      ],
+      "text/plain": [
+       "     Param  Coef   LCL   UCL\n",
+       "0  alpha_0 -1.74 -1.95 -1.53\n",
+       "1  alpha_1 -0.30 -0.83  0.24\n",
+       "2     mu_0  0.14  0.11  0.17\n",
+       "3     mu_1  0.15  0.09  0.22\n",
+       "4    delta  0.01 -0.06  0.08"
+      ]
+     },
+     "execution_count": 14,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "# Formatting results into a nice table\n",
+    "result = pd.DataFrame()\n",
+    "result['Param'] = ['alpha_0', 'alpha_1', 'mu_0', 'mu_1', 'delta']\n",
+    "result['Coef'] = estr.theta\n",
+    "ci = estr.confidence_intervals()\n",
+    "result['LCL'] = ci[:, 0]\n",
+    "result['UCL'] = ci[:, 1]\n",
+    "result.round(2)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "c81ea84b",
+   "metadata": {},
+   "source": [
+    "Again, these results match those reported in the publication\n",
+    "\n",
+    "## Example 3: Outcome misclassification\n",
+    "\n",
+    "For the final example "
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 15,
+   "id": "ae7d0a9e",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Loading in data for the fusion example\n",
+    "d = pd.DataFrame()\n",
+    "d['R'] = [1, 1, 0, 0, 0, 0]           # R or population indicator\n",
+    "d['Y'] = [0, 0, 1, 1, 0, 0]           # True outcome\n",
+    "d['W'] = [1, 0, 1, 0, 1, 0]           # Measured outcome\n",
+    "d['n'] = [680, 270, 204, 38, 18, 71]  # Counts\n",
+    "d['intercept'] = 1                    # Intercept is always 1\n",
+    "\n",
+    "# Expanding out data \n",
+    "d = pd.DataFrame(np.repeat(d.values, d['n'], axis=0),  # Expanding compact data frame\n",
+    "                 columns=d.columns)                    # ... keeping column names\n",
+    "d = d[['intercept', 'R', 'W', 'Y']].copy()             # Dropping the n column\n",
+    "\n",
+    "# Converting to arrays to simplify process\n",
+    "r = np.asarray(d['R'])\n",
+    "w = np.asarray(d['W'])\n",
+    "y = np.asarray(d['Y'])"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 16,
+   "id": "a89cb741",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def psi(theta):\n",
+    "    return ee_rogan_gladen(theta=theta,    # Parameter of interest\n",
+    "                           y=d['Y'],       # ... correct measure\n",
+    "                           y_star=d['W'],  # ... mismeasure\n",
+    "                           r=d['R'])       # ... sample indicator"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 17,
+   "id": "a5a1df56",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "estr = MEstimator(psi, init=[0.75, 0.75, 0.75, 0.75])\n",
+    "estr.estimate()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 18,
+   "id": "216cf37b",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/html": [
+       "<div>\n",
+       "<style scoped>\n",
+       "    .dataframe tbody tr th:only-of-type {\n",
+       "        vertical-align: middle;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe tbody tr th {\n",
+       "        vertical-align: top;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe thead th {\n",
+       "        text-align: right;\n",
+       "    }\n",
+       "</style>\n",
+       "<table border=\"1\" class=\"dataframe\">\n",
+       "  <thead>\n",
+       "    <tr style=\"text-align: right;\">\n",
+       "      <th></th>\n",
+       "      <th>Param</th>\n",
+       "      <th>Coef</th>\n",
+       "      <th>LCL</th>\n",
+       "      <th>UCL</th>\n",
+       "    </tr>\n",
+       "  </thead>\n",
+       "  <tbody>\n",
+       "    <tr>\n",
+       "      <th>0</th>\n",
+       "      <td>Corrected</td>\n",
+       "      <td>0.80</td>\n",
+       "      <td>0.72</td>\n",
+       "      <td>0.88</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>1</th>\n",
+       "      <td>Mismeasured</td>\n",
+       "      <td>0.72</td>\n",
+       "      <td>0.69</td>\n",
+       "      <td>0.74</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>2</th>\n",
+       "      <td>Sensitivity</td>\n",
+       "      <td>0.84</td>\n",
+       "      <td>0.80</td>\n",
+       "      <td>0.89</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>3</th>\n",
+       "      <td>Specificity</td>\n",
+       "      <td>0.80</td>\n",
+       "      <td>0.71</td>\n",
+       "      <td>0.88</td>\n",
+       "    </tr>\n",
+       "  </tbody>\n",
+       "</table>\n",
+       "</div>"
+      ],
+      "text/plain": [
+       "         Param  Coef   LCL   UCL\n",
+       "0    Corrected  0.80  0.72  0.88\n",
+       "1  Mismeasured  0.72  0.69  0.74\n",
+       "2  Sensitivity  0.84  0.80  0.89\n",
+       "3  Specificity  0.80  0.71  0.88"
+      ]
+     },
+     "execution_count": 18,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "# Formatting results into a nice table\n",
+    "result = pd.DataFrame()\n",
+    "result['Param'] = ['Corrected', 'Mismeasured', 'Sensitivity', 'Specificity']\n",
+    "result['Coef'] = estr.theta\n",
+    "ci = estr.confidence_intervals()\n",
+    "result['LCL'] = ci[:, 0]\n",
+    "result['UCL'] = ci[:, 1]\n",
+    "result.round(2)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "fffd12f1",
+   "metadata": {},
+   "source": [
+    "These results match Ross et al.\n",
+    "\n",
+    "### Conclusions\n",
+    "\n",
+    "Here, we replicated the introduction to M-estimation tutorial provided in Ross et al. The basic ideas illustrated in that paper and replicated here can be extended in numerous directions.\n",
+    "\n",
+    "### References\n",
+    "\n",
+    "Ross RK, Zivich PN, Stringer JS, & Cole SR. (2024). M-estimation for common epidemiological measures: introduction and applied examples. *International Journal of Epidemiology*, 53(2), dyae030."
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3 (ipykernel)",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.9.4"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/docs/Examples/index.rst b/docs/Examples/index.rst
index 9b0085e..2b5d788 100644
--- a/docs/Examples/index.rst
+++ b/docs/Examples/index.rst
@@ -9,8 +9,10 @@ user-built estimating equations.
 .. toctree::
   :maxdepth: 2
 
-  Boos-Stefanski-Ch7
+  Ross-IJE-2024
   LifeScienceExamples
+  Cole-AJE-2023
+  Boos-Stefanski-Ch7
   Hernan-Robins-2023
   Morris-Trials-2022
   Generalized-Additive-Model
diff --git a/docs/Reference/Estimating Equations.rst b/docs/Reference/Estimating Equations.rst
index e60aba6..c80c5dd 100644
--- a/docs/Reference/Estimating Equations.rst
+++ b/docs/Reference/Estimating Equations.rst
@@ -28,6 +28,7 @@ Regression
   :toctree: generated/
 
   ee_regression
+  ee_mlogit
   ee_glm
   ee_robust_regression
   ee_ridge_regression
@@ -37,6 +38,17 @@ Regression
   ee_additive_regression
 
 
+Measurement
+-------------------
+
+.. currentmodule:: delicatessen.estimating_equations.measurement
+
+.. autosummary::
+  :toctree: generated/
+
+  ee_rogan_gladen
+
+
 Survival
 -------------------
 
diff --git a/docs/Reference/Utilities.rst b/docs/Reference/Utilities.rst
index e7c06bc..e2b5cdb 100644
--- a/docs/Reference/Utilities.rst
+++ b/docs/Reference/Utilities.rst
@@ -18,6 +18,11 @@ Data transformations
   robust_loss_functions
   spline
   regression_predictions
+  polygamma
+  digamma
+  standard_normal_cdf
+  standard_normal_pdf
+
 
 Design matrices
 ---------------------
@@ -27,6 +32,7 @@ Design matrices
 
   additive_design_matrix
 
+
 Differentiation
 ---------------------
 
diff --git a/docs/Reference/generated/delicatessen.estimating_equations.measurement.ee_rogan_gladen.rst b/docs/Reference/generated/delicatessen.estimating_equations.measurement.ee_rogan_gladen.rst
new file mode 100644
index 0000000..af9b240
--- /dev/null
+++ b/docs/Reference/generated/delicatessen.estimating_equations.measurement.ee_rogan_gladen.rst
@@ -0,0 +1,6 @@
+﻿delicatessen.estimating\_equations.measurement.ee\_rogan\_gladen
+================================================================
+
+.. currentmodule:: delicatessen.estimating_equations.measurement
+
+.. autofunction:: ee_rogan_gladen
\ No newline at end of file
diff --git a/docs/Reference/generated/delicatessen.estimating_equations.regression.ee_mlogit.rst b/docs/Reference/generated/delicatessen.estimating_equations.regression.ee_mlogit.rst
new file mode 100644
index 0000000..dd28d5f
--- /dev/null
+++ b/docs/Reference/generated/delicatessen.estimating_equations.regression.ee_mlogit.rst
@@ -0,0 +1,6 @@
+﻿delicatessen.estimating\_equations.regression.ee\_mlogit
+========================================================
+
+.. currentmodule:: delicatessen.estimating_equations.regression
+
+.. autofunction:: ee_mlogit
\ No newline at end of file
diff --git a/docs/Reference/generated/delicatessen.utilities.digamma.rst b/docs/Reference/generated/delicatessen.utilities.digamma.rst
new file mode 100644
index 0000000..2621d36
--- /dev/null
+++ b/docs/Reference/generated/delicatessen.utilities.digamma.rst
@@ -0,0 +1,6 @@
+﻿delicatessen.utilities.digamma
+==============================
+
+.. currentmodule:: delicatessen.utilities
+
+.. autofunction:: digamma
\ No newline at end of file
diff --git a/docs/Reference/generated/delicatessen.utilities.polygamma.rst b/docs/Reference/generated/delicatessen.utilities.polygamma.rst
new file mode 100644
index 0000000..2c13d5d
--- /dev/null
+++ b/docs/Reference/generated/delicatessen.utilities.polygamma.rst
@@ -0,0 +1,6 @@
+﻿delicatessen.utilities.polygamma
+================================
+
+.. currentmodule:: delicatessen.utilities
+
+.. autofunction:: polygamma
\ No newline at end of file
diff --git a/docs/Reference/generated/delicatessen.utilities.standard_normal_cdf.rst b/docs/Reference/generated/delicatessen.utilities.standard_normal_cdf.rst
new file mode 100644
index 0000000..93b068c
--- /dev/null
+++ b/docs/Reference/generated/delicatessen.utilities.standard_normal_cdf.rst
@@ -0,0 +1,6 @@
+﻿delicatessen.utilities.standard\_normal\_cdf
+============================================
+
+.. currentmodule:: delicatessen.utilities
+
+.. autofunction:: standard_normal_cdf
\ No newline at end of file
diff --git a/docs/Reference/generated/delicatessen.utilities.standard_normal_pdf.rst b/docs/Reference/generated/delicatessen.utilities.standard_normal_pdf.rst
new file mode 100644
index 0000000..86c24ab
--- /dev/null
+++ b/docs/Reference/generated/delicatessen.utilities.standard_normal_pdf.rst
@@ -0,0 +1,6 @@
+﻿delicatessen.utilities.standard\_normal\_pdf
+============================================
+
+.. currentmodule:: delicatessen.utilities
+
+.. autofunction:: standard_normal_pdf
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 46f0609..6396828 100644
--- a/setup.py
+++ b/setup.py
@@ -34,6 +34,7 @@
                    'Programming Language :: Python :: 3.9',
                    'Programming Language :: Python :: 3.10',
                    'Programming Language :: Python :: 3.11',
+                   'Programming Language :: Python :: 3.12',
                    ],
       install_requires=['numpy>=1.18.5',
                         'scipy>=1.9.0',


Test patch:
diff --git a/tests/test_MEstimation.py b/tests/test_MEstimation.py
index bd39e26..3ad083c 100644
--- a/tests/test_MEstimation.py
+++ b/tests/test_MEstimation.py
@@ -1,3 +1,7 @@
+####################################################################################################################
+# Tests for M-estimator features
+####################################################################################################################
+
 import pytest
 import numpy as np
 import numpy.testing as npt
diff --git a/tests/test_MEstimation_examples.py b/tests/test_MEstimation_examples.py
index 500eb03..25baeec 100644
--- a/tests/test_MEstimation_examples.py
+++ b/tests/test_MEstimation_examples.py
@@ -1,3 +1,7 @@
+####################################################################################################################
+# Tests for M-estimator provided examples (as a double-check)
+####################################################################################################################
+
 import pytest
 import numpy as np
 import pandas as pd
diff --git a/tests/test_autodiff.py b/tests/test_autodiff.py
index 2a5bb17..24203c8 100644
--- a/tests/test_autodiff.py
+++ b/tests/test_autodiff.py
@@ -1,3 +1,7 @@
+####################################################################################################################
+# Tests for automatic differentiation procedures
+####################################################################################################################
+
 import pytest
 import numpy as np
 import numpy.testing as npt
diff --git a/tests/test_data.py b/tests/test_data.py
index f54681b..2e39bff 100644
--- a/tests/test_data.py
+++ b/tests/test_data.py
@@ -1,3 +1,7 @@
+####################################################################################################################
+# Tests for loading provided data
+####################################################################################################################
+
 import pytest
 
 from delicatessen.data import load_shaq_free_throws, load_inderjit
diff --git a/tests/test_ee_basic.py b/tests/test_ee_basic.py
new file mode 100644
index 0000000..ea4175c
--- /dev/null
+++ b/tests/test_ee_basic.py
@@ -0,0 +1,79 @@
+####################################################################################################################
+# Tests for built-in estimating equations -- basic
+####################################################################################################################
+
+import numpy as np
+import numpy.testing as npt
+import pytest
+
+from delicatessen import MEstimator
+from delicatessen.estimating_equations import ee_mean, ee_mean_variance, ee_mean_robust
+
+
+class TestEstimatingEquationsBase:
+
+    @pytest.fixture
+    def y(self):
+        return np.array([5, 1, 2, 4, 2, 4, 5, 7, 11, 1, 6, 3, 4, 6])
+
+    @pytest.fixture
+    def yr(self):
+        return np.array([-10, -1, 2, 3, -2, 0, 3, 5, 12])
+
+    def test_mean(self, y):
+        def psi1(theta):
+            return y - theta
+
+        mcee = MEstimator(psi1, init=[0, ])
+        mcee.estimate()
+
+        def psi2(theta):
+            return ee_mean(theta, y=y)
+
+        mpee = MEstimator(psi2, init=[0, ])
+        mpee.estimate()
+
+        # Checking mean estimate
+        npt.assert_allclose(mcee.theta, mpee.theta, atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mcee.asymptotic_variance, mpee.asymptotic_variance, atol=1e-6)
+
+    def test_mean_robust(self, yr):
+        def byhand(theta):
+            k = 6
+            ee_rm = np.array(yr) - theta
+            ee_rm = np.where(ee_rm > k, k, ee_rm)
+            ee_rm = np.where(ee_rm < -k, -k, ee_rm)
+            return ee_rm
+
+        ref = MEstimator(byhand, init=[0, ])
+        ref.estimate()
+
+        def psi(theta):
+            return ee_mean_robust(theta=theta, y=yr, k=6)
+
+        mestimator = MEstimator(psi, init=[0, ])
+        mestimator.estimate()
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta[0], ref.theta[0], atol=1e-9)
+
+    def test_mean_variance(self, y):
+        def psi1(theta):
+            return y - theta[0], (y - theta[0]) ** 2 - theta[1]
+
+        mcee = MEstimator(psi1, init=[0, 0, ])
+        mcee.estimate()
+
+        def psi2(theta):
+            return ee_mean_variance(theta=theta, y=y)
+
+        mpee = MEstimator(psi2, init=[0, 0, ])
+        mpee.estimate()
+
+        # Checking mean estimate
+        npt.assert_allclose(mcee.theta, mpee.theta, atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mcee.asymptotic_variance, mpee.asymptotic_variance, atol=1e-6)
diff --git a/tests/test_ee_causal.py b/tests/test_ee_causal.py
new file mode 100644
index 0000000..b6bdb08
--- /dev/null
+++ b/tests/test_ee_causal.py
@@ -0,0 +1,681 @@
+####################################################################################################################
+# Tests for built-in estimating equations -- causal
+####################################################################################################################
+
+import pytest
+import numpy as np
+import numpy.testing as npt
+import pandas as pd
+import statsmodels.api as sm
+
+from delicatessen import MEstimator
+from delicatessen.estimating_equations import (ee_regression,
+                                               ee_gformula, ee_ipw, ee_ipw_msm, ee_aipw, ee_gestimation_snmm,
+                                               ee_mean_sensitivity_analysis)
+from delicatessen.utilities import inverse_logit
+
+
+class TestEstimatingEquationsCausal:
+
+    @pytest.fixture
+    def data_causal_b(self):
+        d = pd.DataFrame()
+        d['W'] = [1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
+                  1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3]
+        d['V'] = [1, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0,
+                  1, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0]
+        d['A'] = [1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 1,
+                  1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 1]
+        d['Y'] = [0, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1, 1, 1, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0,
+                  1, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0]
+        d['I'] = 1
+        return d
+
+    @pytest.fixture
+    def data_causal_c(self):
+        d = pd.DataFrame()
+        d['W'] = [1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
+                  1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3]
+        d['V'] = [1, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0,
+                  1, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0]
+        d['A'] = [1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 1,
+                  1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 1]
+        d['Y'] = [3, 5, 1, 5, 2, 5, 2, 1, 4, 2, 3, 4, 2, 5, 5, 3, 5, 1, 5, 2, 5, 2, 1, 4, 2, 3, 4, 2, 5, 5,
+                  3, 5, 1, 5, 2, 5, 2, 1, 4, 2, 3, 4, 2, 5, 5]
+        d['I'] = 1
+        return d
+
+    @pytest.fixture
+    def data_causal_m(self):
+        d = pd.DataFrame()
+        d['W'] = [1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
+                  1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
+                  1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3]
+        d['V'] = [1, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0,
+                  1, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0,
+                  1, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0]
+        d['A'] = [1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 1,
+                  1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 1,
+                  1, 1, 1, 1, 0, 1, 0, 1, 1, 1, 1, 0, 1, 0, 1]
+        d['Y'] = [3, 5, 1, 5, 2, 5, 2, 1, 4, 2, 3, 4, 2, 5, 5,
+                  3, 5, 1, 5, 2, 5, 2, 1, 4, 2, 3, 4, 2, 5, 5,
+                  ] + [np.nan, ]*15
+        d['I'] = 1
+        return d
+
+    def test_gformula(self, data_causal_b):
+        d = data_causal_b
+        d1 = d.copy()
+        d1['A'] = 1
+        d0 = d.copy()
+        d0['A'] = 0
+
+        # M-estimation
+        def psi(theta):
+            return ee_gformula(theta,
+                               y=d['Y'], X=d[['I', 'A', 'W']],
+                               X1=d1[['I', 'A', 'W']],
+                               X0=d0[['I', 'A', 'W']])
+
+        estr = MEstimator(psi, init=[0., 0.5, 0.5, 0., 0., 0.])
+        estr.estimate(solver='lm')
+
+        # By-hand g-formula with statsmodels
+        glm = sm.GLM(d['Y'], d[['I', 'A', 'W']], family=sm.families.Binomial()).fit()
+        cd = d[['I', 'A', 'W']].copy()
+        cd['A'] = 1
+        ya1 = glm.predict(cd)
+        cd['A'] = 0
+        ya0 = glm.predict(cd)
+
+        # Checking logistic coefficients (nuisance model estimates)
+        npt.assert_allclose(estr.theta[3:],
+                            np.asarray(glm.params),
+                            atol=1e-6)
+        # Checking mean estimates
+        npt.assert_allclose(estr.theta[0],
+                            np.mean(ya1) - np.mean(ya0),
+                            atol=1e-6)
+        npt.assert_allclose(estr.theta[1],
+                            np.mean(ya1),
+                            atol=1e-6)
+        npt.assert_allclose(estr.theta[2],
+                            np.mean(ya0),
+                            atol=1e-6)
+
+    def test_gcomp_bad_dimensions_error(self, data_causal_b):
+        d = data_causal_b
+        d1 = d.copy()
+        d1['A'] = 1
+        d0 = d.copy()
+        d0['A'] = 0
+
+        # M-estimation
+        def psi(theta):
+            return ee_gformula(theta,
+                               y=d['Y'], X=d[['I', 'A', 'W']],
+                               X1=d1[['I', 'W']])
+
+        mestimator = MEstimator(psi, init=[0.5, 0., 0., 0.])
+        with pytest.raises(ValueError, match="The dimensions of X and X1"):
+            mestimator.estimate(solver='lm')
+
+        def psi(theta):
+            return ee_gformula(theta,
+                               y=d['Y'], X=d[['I', 'A', 'W']],
+                               X1=d1[['I', 'A', 'W']],
+                               X0=d0[['I', 'A']])
+
+        mestimator = MEstimator(psi, init=[0., 0.5, 0.5, 0., 0., 0.])
+        with pytest.raises(ValueError, match="The dimensions of X and X0"):
+            mestimator.estimate(solver='lm')
+
+    def test_ipw(self, data_causal_b):
+        d = data_causal_b
+
+        def psi(theta):
+            return ee_ipw(theta, y=d['Y'], A=d['A'], W=d[['I', 'W']])
+
+        mestimator = MEstimator(psi, init=[0., 0.5, 0.5, 0., 0.])
+        mestimator.estimate(solver='lm')
+
+        # By-hand IPW estimator with statsmodels
+        glm = sm.GLM(d['A'], d[['I', 'W']], family=sm.families.Binomial()).fit()
+        pi = glm.predict()
+        ya1 = d['A'] * d['Y'] / pi
+        ya0 = (1-d['A']) * d['Y'] / (1-pi)
+
+        # Checking logistic coefficients (nuisance model estimates)
+        npt.assert_allclose(mestimator.theta[3:],
+                            np.asarray(glm.params),
+                            atol=1e-6)
+        # Checking mean estimates
+        npt.assert_allclose(mestimator.theta[0],
+                            np.mean(ya1) - np.mean(ya0),
+                            atol=1e-6)
+        npt.assert_allclose(mestimator.theta[1],
+                            np.mean(ya1),
+                            atol=1e-6)
+        npt.assert_allclose(mestimator.theta[2],
+                            np.mean(ya0),
+                            atol=1e-6)
+
+    def test_ipw_truncate(self, data_causal_b):
+        d = data_causal_b
+
+        def psi(theta):
+            return ee_ipw(theta, y=d['Y'], A=d['A'], W=d[['I', 'W']],
+                          truncate=(0.1, 0.5))
+
+        mestimator = MEstimator(psi, init=[0., 0.5, 0.5, 0., 0.])
+        mestimator.estimate(solver='lm')
+
+        # By-hand IPW estimator with statsmodels
+        glm = sm.GLM(d['A'], d[['I', 'W']], family=sm.families.Binomial()).fit()
+        pi = glm.predict()
+        pi = np.clip(pi, 0.1, 0.5)
+        ya1 = d['A'] * d['Y'] / pi
+        ya0 = (1-d['A']) * d['Y'] / (1-pi)
+
+        # Checking logistic coefficients (nuisance model estimates)
+        npt.assert_allclose(mestimator.theta[3:],
+                            np.asarray(glm.params),
+                            atol=1e-6)
+        # Checking mean estimates
+        npt.assert_allclose(mestimator.theta[0],
+                            np.mean(ya1) - np.mean(ya0),
+                            atol=1e-6)
+        npt.assert_allclose(mestimator.theta[1],
+                            np.mean(ya1),
+                            atol=1e-6)
+        npt.assert_allclose(mestimator.theta[2],
+                            np.mean(ya0),
+                            atol=1e-6)
+
+    def test_ipw_truncate_error(self, data_causal_b):
+        d = data_causal_b
+
+        def psi(theta):
+            return ee_ipw(theta, y=d['Y'], A=d['A'], W=d[['I', 'W']],
+                          truncate=(0.99, 0.01))
+
+        mestimator = MEstimator(psi, init=[0., 0.5, 0.5, 0., 0.])
+        with pytest.raises(ValueError, match="truncate values"):
+            mestimator.estimate()
+
+    def test_ipw_weights(self, data_causal_m):
+        # Setting up data
+        d = data_causal_m
+        W = d[['I', 'V', 'W']]
+        X = d[['I', 'A']]
+        a = d['A']
+        y = d['Y']
+        r = np.where(d['Y'].isna(), 0, 1)
+
+        # M-estimation
+        def psi(theta):
+            # Separating parameters out
+            alpha = theta[:3 + W.shape[1]]  # MSM & PS
+            gamma = theta[3 + W.shape[1]:]  # Missing score
+
+            # Estimating equation for IPMW
+            ee_ms = ee_regression(theta=gamma,
+                                  X=X, y=r,
+                                  model='logistic')
+            pi_m = inverse_logit(np.dot(X, gamma))
+            ipmw = r / pi_m
+
+            # Estimating equations for MSM and PS
+            ee_msm = ee_ipw(theta=alpha, y=y, A=a, W=W,
+                            weights=ipmw)
+            ee_msm = np.nan_to_num(ee_msm, copy=False, nan=0.)
+            return np.vstack([ee_msm, ee_ms])
+
+        init_vals = [0., 3., 3., ] + [0., 0., 0.] + [0., 0.]
+        mestr = MEstimator(psi, init=init_vals)
+        mestr.estimate(solver='lm')
+
+        # By-hand IPW estimator with statsmodels
+        glm_ps = sm.GLM(a, W, family=sm.families.Binomial()).fit()
+        pi_a = glm_ps.predict()
+        glm_ms = sm.GLM(r, X, family=sm.families.Binomial()).fit()
+        pi_m = glm_ms.predict()
+        ipmw = r / pi_m
+
+        ya1 = d['A'] * d['Y'] / pi_a * ipmw
+        ya0 = (1-d['A']) * d['Y'] / (1-pi_a) * ipmw
+
+        # Checking logistic coefficients (nuisance model estimates)
+        npt.assert_allclose(mestr.theta[3:6],
+                            np.asarray(glm_ps.params),
+                            atol=1e-6)
+        npt.assert_allclose(mestr.theta[6:],
+                            np.asarray(glm_ms.params),
+                            atol=1e-6)
+        # Checking mean estimates
+        npt.assert_allclose(mestr.theta[0],
+                            np.mean(ya1) - np.mean(ya0),
+                            atol=1e-6)
+        npt.assert_allclose(mestr.theta[1],
+                            np.mean(ya1),
+                            atol=1e-6)
+        npt.assert_allclose(mestr.theta[2],
+                            np.mean(ya0),
+                            atol=1e-6)
+
+    def test_ipw_msm(self, data_causal_b):
+        d = data_causal_b
+
+        def psi(theta):
+            return ee_ipw_msm(theta, y=d['Y'], A=d['A'],
+                              W=d[['I', 'W']], V=d[['I', 'A']],
+                              link='logit', distribution='binomial')
+
+        mestimator = MEstimator(psi, init=[0., 0., 0., 0.])
+        mestimator.estimate(solver='lm')
+
+        # By-hand IPW estimator with statsmodels
+        glm = sm.GLM(d['A'], d[['I', 'W']],
+                     family=sm.families.Binomial()).fit()
+        pi = glm.predict()
+        ipw = np.where(d['A'] == 1, 1/pi, 1/(1-pi))
+        msm = sm.GEE(d['Y'], d[['I', 'A']], family=sm.families.Binomial(), weights=ipw,
+                     groups=d.index).fit()
+
+        # Checking logistic coefficients (nuisance model estimates)
+        npt.assert_allclose(mestimator.theta[2:],
+                            np.asarray(glm.params),
+                            atol=1e-6)
+        # Checking mean estimates
+        npt.assert_allclose(mestimator.theta[0:2],
+                            msm.params,
+                            atol=1e-6)
+
+    def test_ipw_msm_truncate(self, data_causal_b):
+        d = data_causal_b
+
+        def psi(theta):
+            return ee_ipw_msm(theta, y=d['Y'], A=d['A'], W=d[['I', 'W']], V=d[['I', 'A']],
+                              link='logit', distribution='binomial', truncate=(0.1, 0.5))
+
+        mestimator = MEstimator(psi, init=[0., 0., 0., 0.])
+        mestimator.estimate(solver='lm')
+
+        # By-hand IPW estimator with statsmodels
+        glm = sm.GLM(d['A'], d[['I', 'W']], family=sm.families.Binomial()).fit()
+        pi = glm.predict()
+        pi = np.clip(pi, 0.1, 0.5)
+        ipw = np.where(d['A'] == 1, 1/pi, 1/(1-pi))
+        msm = sm.GEE(d['Y'], d[['I', 'A']], family=sm.families.Binomial(), weights=ipw,
+                     groups=d.index).fit()
+
+        # Checking logistic coefficients (nuisance model estimates)
+        npt.assert_allclose(mestimator.theta[2:],
+                            np.asarray(glm.params),
+                            atol=1e-6)
+        # Checking mean estimates
+        npt.assert_allclose(mestimator.theta[0:2],
+                            msm.params,
+                            atol=1e-6)
+
+    def test_ipw_msm_weights(self, data_causal_m):
+        # Setting up data
+        d = data_causal_m.copy()
+        W = d[['I', 'V', 'W']]
+        X = d[['I', 'A']]
+        msm = d[['I', 'A']]
+        a = d['A']
+        y = d['Y']
+        r = np.where(d['Y'].isna(), 0, 1)
+
+        # M-estimation
+        def psi(theta):
+            # Separating parameters out
+            alpha = theta[:2 + W.shape[1]]  # MSM & PS
+            gamma = theta[2 + W.shape[1]:]  # Missing score
+
+            # Estimating equation for IPMW
+            ee_ms = ee_regression(theta=gamma,
+                                  X=X, y=r,
+                                  model='logistic')
+            pi_m = inverse_logit(np.dot(X, gamma))
+            ipmw = r / pi_m
+
+            # Estimating equations for MSM and PS
+            ee_msm = ee_ipw_msm(alpha,
+                                y=y, A=a, W=W, V=msm,
+                                link='log', distribution='poisson',
+                                weights=ipmw)
+            ee_msm = np.nan_to_num(ee_msm, copy=False, nan=0.)
+            return np.vstack([ee_msm, ee_ms])
+
+        init_vals = [0., 0., ] + [0., 0., 0.] + [0., 0.]
+        mestr = MEstimator(psi, init=init_vals)
+        mestr.estimate(solver='lm', maxiter=5000)
+
+        # By-hand IPW estimator with statsmodels
+        glm_ps = sm.GLM(a, W, family=sm.families.Binomial()).fit()
+        pi_a = glm_ps.predict()
+        iptw = np.where(a == 1, 1/pi_a, 1/(1-pi_a))
+        glm_ms = sm.GLM(r, X, family=sm.families.Binomial()).fit()
+        pi_m = glm_ms.predict()
+        ipmw = r / pi_m
+        d['ipw'] = ipmw*iptw
+
+        dcc = d.dropna()
+        msm = sm.GEE(dcc['Y'], dcc[['I', 'A']], weights=dcc['ipw'],
+                     family=sm.families.Poisson(), groups=dcc.index).fit()
+
+        # Checking logistic coefficients (nuisance model estimates)
+        npt.assert_allclose(mestr.theta[2:5],
+                            np.asarray(glm_ps.params),
+                            atol=1e-6)
+        npt.assert_allclose(mestr.theta[5:],
+                            np.asarray(glm_ms.params),
+                            atol=1e-6)
+        # Checking mean estimates
+        npt.assert_allclose(mestr.theta[0:2],
+                            msm.params,
+                            atol=1e-6)
+
+    def test_aipw(self, data_causal_b):
+        d = data_causal_b
+        d1 = d.copy()
+        d1['A'] = 1
+        d0 = d.copy()
+        d0['A'] = 0
+
+        # M-estimation
+        def psi_builtin_regression(theta):
+            return ee_aipw(theta, y=d['Y'], A=d['A'],
+                           W=d[['I', 'W']], X=d[['I', 'A', 'W']],
+                           X1=d1[['I', 'A', 'W']], X0=d0[['I', 'A', 'W']])
+
+        mestimator = MEstimator(psi_builtin_regression, init=[0., 0.5, 0.5,   # Parameters of interest
+                                                              0., 0., 0.,     # Outcome nuisance model
+                                                              0., 0.])        # Treatment nuisance model
+        mestimator.estimate(solver='lm', tolerance=1e-12)
+
+        # By-hand IPW estimator with statsmodels
+        pi_m = sm.GLM(d['A'], d[['I', 'W']],
+                      family=sm.families.Binomial()).fit()
+        y_m = sm.GLM(d['Y'], d[['I', 'A', 'W']],
+                     family=sm.families.Binomial()).fit()
+        # Predicting coefficients
+        pi = pi_m.predict()
+        cd = d[['I', 'A', 'W']].copy()
+        cd['A'] = 1
+        ya1 = y_m.predict(cd)
+        cd['A'] = 0
+        ya0 = y_m.predict(cd)
+        # AIPW estimator
+        ya1_star = d['Y'] * d['A'] / pi - ya1 * (d['A'] - pi) / pi
+        ya0_star = d['Y'] * (1-d['A']) / (1-pi) - ya0 * (pi - d['A']) / (1-pi)
+        # AIPW variance estimator!
+        var_ate = np.nanvar((ya1_star - ya0_star) - np.mean(ya1_star - ya0_star), ddof=1) / d.shape[0]
+        var_r1 = np.nanvar(ya1_star - np.mean(ya1_star), ddof=1) / d.shape[0]
+        var_r0 = np.nanvar(ya0_star - np.mean(ya0_star), ddof=1) / d.shape[0]
+
+        # Checking logistic coefficients (nuisance model estimates)
+        npt.assert_allclose(mestimator.theta[3:5],
+                            np.asarray(pi_m.params),
+                            atol=1e-6)
+        npt.assert_allclose(mestimator.theta[5:],
+                            np.asarray(y_m.params),
+                            atol=1e-6)
+
+        # Checking mean estimates
+        npt.assert_allclose(mestimator.theta[0],
+                            np.mean(ya1_star) - np.mean(ya0_star),
+                            atol=1e-6)
+        npt.assert_allclose(mestimator.theta[1],
+                            np.mean(ya1_star),
+                            atol=1e-6)
+        npt.assert_allclose(mestimator.theta[2],
+                            np.mean(ya0_star),
+                            atol=1e-6)
+
+    def test_gestimaton_linear(self, data_causal_c):
+        d = data_causal_c
+        d['VW'] = d['V']*d['W']
+
+        # M-estimator
+        def psi(theta):
+            return ee_gestimation_snmm(theta=theta, y=d['Y'], A=d['A'],
+                                       W=d[['I', 'V', 'W']], V=d[['I', 'V']],
+                                       model='linear')
+
+        mestr = MEstimator(psi, init=[0., ] * 5)
+        mestr.estimate(solver='lm')
+
+        # Previously solved SNM using zEpid
+        snm_params = [0.499264398938, -0.400700107829]
+
+        # Previously solved variance
+        snm_var = [[0.5309405889911, -0.5497523512113, -0.08318452996742, 0.02706302088976,  0.02666983404626],
+                   [-0.5497523512113,  0.9114476962859,  0.20188395780370, -8.543908429690e-04, -0.08753380928183],
+                   [-0.08318452996742, 0.2018839578037,  0.84188596592780, -0.2000575145713, -0.2959629818969],
+                   [0.02706302088976, -8.543908429690e-04, -0.2000575145713, 0.4010462043495, -0.03045175600959],
+                   [0.02666983404626, -0.08753380928183, -0.2959629818969, -0.03045175600959,  0.1505645314902]]
+
+        # Checking SNM parameters
+        npt.assert_allclose(mestr.theta[0:2],
+                            snm_params,
+                            atol=1e-7)
+
+        # Checking variance
+        npt.assert_allclose(mestr.variance,
+                            snm_var,
+                            atol=1e-4)
+
+    def test_gestimation_linear_weighted(self, data_causal_m):
+        # Setting up data for estimation equation
+        d = data_causal_m
+        W = d[['I', 'V', 'W']]
+        X = d[['I', 'A']]
+        snm = d[['I', 'V']]
+        a = d['A']
+        y = d['Y']
+        r = np.where(d['Y'].isna(), 0, 1)
+
+        # M-estimator
+        def psi(theta):
+            # Dividing parameters into corresponding estimation equations
+            alpha = theta[:snm.shape[1] + W.shape[1]]  # SNM and PS
+            gamma = theta[snm.shape[1] + W.shape[1]:]  # Missing scores
+
+            # Estimating equation for IPMW
+            ee_ms = ee_regression(theta=gamma,  # Missing score
+                                  X=X, y=r,  # ... observed data
+                                  model='logistic')  # ... logit model
+            pi_m = inverse_logit(np.dot(X, gamma))  # Predicted prob
+            ipmw = r / pi_m  # Missing weights
+
+            # Estimating equations for PS
+            ee_snm = ee_gestimation_snmm(theta=alpha, y=y, A=a,
+                                         W=W, V=snm,
+                                         model='linear', weights=ipmw)
+            # Setting rows with missing Y's as zero (no contribution)
+            ee_snm = np.nan_to_num(ee_snm, copy=False, nan=0.)
+            return np.vstack([ee_snm, ee_ms])
+
+        init_values = [0., 0.] + [0., ]*3 + [0., ]*2
+        mestr = MEstimator(psi, init=init_values)
+        mestr.estimate(solver='lm')
+
+        # Comparison using zEpid
+        # from zepid.causal.snm import GEstimationSNM
+        # snm = GEstimationSNM(d, exposure='A', outcome='Y')
+        # snm.exposure_model("V + W")
+        # snm.missing_model("A", stabilized=False)
+        # snm.structural_nested_model("A + A:V")
+        # snm.fit()
+        snm_params = [0.478757884654, -0.366956950389,
+                      2.406293643425, -0.750506882472, -0.816256976888,
+                      1.252762968495, -0.878069519054]
+
+        # Previously solved variance
+        snm_var = [[0.833658518465, -0.863011204849],
+                   [-0.863011204849, 1.397401367295]]
+
+        # Checking SNM parameters
+        npt.assert_allclose(mestr.theta,
+                            snm_params,
+                            atol=1e-7)
+
+        # Checking variance
+        npt.assert_allclose(mestr.variance[:2, :2],
+                            snm_var,
+                            atol=1e-4)
+
+    def test_gestimation_linear_efficient(self, data_causal_c):
+        d = data_causal_c
+
+        # M-estimator
+        def psi(theta):
+            return ee_gestimation_snmm(theta=theta, y=d['Y'], A=d['A'],
+                                       W=d[['I', 'V', 'W']], V=d[['I', 'W']],
+                                       X=d[['I', 'V', 'W']], model='linear')
+
+        mestr = MEstimator(psi, init=[0., ] * 8)
+        mestr.estimate(solver='lm')
+
+        # Comparing nuisance models
+        npt.assert_allclose(mestr.theta[2:5], [2.06553139, -0.65396442, -0.80506948],
+                            atol=1e-7)
+        npt.assert_allclose(mestr.theta[5:], [0.37758292, -0.03818214, 1.28057925],
+                            atol=1e-7)
+
+        smm_params = [2.70724664, -1.17655853]
+        smm_var = [[1.10055729, -0.63263541],
+                   [-0.63263541, 0.41548127]]
+
+        # Checking SNM parameters
+        npt.assert_allclose(mestr.theta[:2], smm_params,
+                            atol=1e-7)
+
+        # Checking variance
+        npt.assert_allclose(mestr.variance[:2, :2], smm_var,
+                            atol=1e-4)
+
+    def test_gestimation_poisson(self, data_causal_c):
+        d = data_causal_c
+        ps_nuisance = [2.06553139, -0.65396442, -0.80506948]
+
+        # Inefficient g-estimator
+        def psi(theta):
+            return ee_gestimation_snmm(theta=theta, y=d['Y'], A=d['A'],
+                                       W=d[['I', 'V', 'W']], V=d[['I', 'W']],
+                                       model='poisson')
+
+        mestr = MEstimator(psi, init=[0., ] * 5)
+        mestr.estimate(solver='lm')
+
+        # Comparing nuisance models
+        npt.assert_allclose(mestr.theta[2:], ps_nuisance, atol=1e-7)
+
+        # Comparing SMM parameters
+        smm_params = [0.72207801, -0.28975432]
+        smm_var = [[0.15162332, -0.06732043],
+                   [-0.06732043,  0.03567282]]
+        npt.assert_allclose(mestr.theta[:2], smm_params,
+                            atol=1e-7)
+        npt.assert_allclose(mestr.variance[:2, :2], smm_var,
+                            atol=1e-4)
+
+        # Efficient g-estimator
+        def psi(theta):
+            return ee_gestimation_snmm(theta=theta, y=d['Y'], A=d['A'],
+                                       W=d[['I', 'V', 'W']], V=d[['I', 'W']],
+                                       X=d[['I', 'V', 'W']], model='poisson')
+
+        mestr = MEstimator(psi, init=[0., ] * 8)
+        mestr.estimate(solver='lm')
+
+        # Comparing nuisance models
+        npt.assert_allclose(mestr.theta[2:5], ps_nuisance, atol=1e-7)
+        npt.assert_allclose(mestr.theta[5:], [1.60740877e-01, -1.80166331e-03, 4.31572318e-01], atol=1e-7)
+
+        # Comparing SMM parameters
+        smm_params = [1.04245099, -0.441452325]
+        smm_var = [[0.10928566, -0.06168025],
+                   [-0.06168025, 0.04047553]]
+        npt.assert_allclose(mestr.theta[:2], smm_params,
+                            atol=1e-7)
+        npt.assert_allclose(mestr.variance[:2, :2], smm_var,
+                            atol=1e-4)
+
+    def test_robins_sensitivity_mean(self):
+        d = pd.DataFrame()
+        d['I'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
+        d['X'] = [0, 1, 2, 0, 1, 2, 0, 1, 2, 0]
+        d['Y'] = [7, 2, 5, np.nan, 1, 4, 8, np.nan, 1, np.nan]
+        d['delta'] = np.where(d['Y'].isna(), 0, 1)
+
+        def q_function(y_vals, alpha):
+            y_no_miss = np.where(np.isnan(y_vals), 0, y_vals)
+            return alpha * y_no_miss
+
+        ####
+        # Checking with alpha=0.5
+        def psi(theta):
+            return ee_mean_sensitivity_analysis(theta=theta,
+                                                y=d['Y'], delta=d['delta'], X=d[['I', 'X']],
+                                                q_eval=q_function(d['Y'], alpha=0.5),
+                                                H_function=inverse_logit)
+
+        mestr = MEstimator(psi, init=[0., 0., 0.])
+        mestr.estimate(solver='lm')
+
+        # Checking point estimates
+        npt.assert_allclose(mestr.theta,
+                            [4.42581577, -3.72866034, 3.74204493],
+                            atol=1e-6)
+
+        # Checking variance estimates
+        var_closed = [[0.77498021, 0.00455995, -0.04176666],
+                      [0.00455995, 1.0032351, -0.94005101],
+                      [-0.04176666, -0.94005101, 2.26235294]]
+        npt.assert_allclose(mestr.variance,
+                            var_closed,
+                            atol=1e-6)
+
+        ####
+        # Checking with alpha=-0.5
+        def psi(theta):
+            return ee_mean_sensitivity_analysis(theta=theta,
+                                                y=d['Y'], delta=d['delta'], X=d[['I', 'X']],
+                                                q_eval=q_function(d['Y'], alpha=-0.5),
+                                                H_function=inverse_logit)
+
+        mestr = MEstimator(psi, init=[0., 0., 0.])
+        mestr.estimate(solver='lm')
+
+        # Checking point estimates
+        npt.assert_allclose(mestr.theta,
+                            [4.876513, 3.579188, 0.140411],
+                            atol=1e-6)
+
+        # Checking variance estimates
+        var_closed = [[0.75110762, 0.05973157, -0.11159881],
+                      [0.05973157, 0.88333613, -0.36712181],
+                      [-0.11159881, -0.36712181, 0.52362185]]
+        npt.assert_allclose(mestr.variance,
+                            var_closed,
+                            atol=1e-6)
+
+        ####
+        # Checking complete-case analysis
+        def psi(theta):
+            return ee_mean_sensitivity_analysis(theta=theta,
+                                                y=d['Y'], delta=d['delta'], X=d[['I', ]],
+                                                q_eval=q_function(d['Y'], alpha=0.),
+                                                H_function=inverse_logit)
+
+        mestr = MEstimator(psi, init=[0., 0.])
+        mestr.estimate(solver='lm')
+
+        # Checking point estimates
+        npt.assert_allclose(mestr.theta[0],
+                            np.nanmean(d['Y']),
+                            atol=1e-6)
diff --git a/tests/test_ee_doseresponse.py b/tests/test_ee_doseresponse.py
new file mode 100644
index 0000000..6894188
--- /dev/null
+++ b/tests/test_ee_doseresponse.py
@@ -0,0 +1,153 @@
+####################################################################################################################
+# Tests for built-in estimating equations -- dose-response
+####################################################################################################################
+
+import pytest
+import numpy as np
+import numpy.testing as npt
+
+from delicatessen import MEstimator
+from delicatessen.estimating_equations import ee_2p_logistic, ee_3p_logistic, ee_4p_logistic, ee_effective_dose_delta
+from delicatessen.data import load_inderjit
+
+
+np.random.seed(236461)
+
+
+class TestEstimatingEquationsDoseResponse:
+
+    def test_4pl(self):
+        # Compares against R's drc library:
+        #
+        # library(drc)
+        # library(sandwich)
+        # library(lmtest)
+        # data(ryegrass)
+        # rgll4 = drm(rootl ~ conc, data=ryegrass, fct=LL.4())
+        # coeftest(rgll4, vcov=sandwich)
+        # Results from Ritz et al. and R
+        comparison_theta = np.asarray([0.48141, 3.05795, 2.98222, 7.79296])
+        comparison_var = np.asarray([0.12779, 0.26741, 0.47438, 0.15311])
+
+        d = load_inderjit()
+        dose_data = d[:, 1]
+        resp_data = d[:, 0]
+
+        def psi(theta):
+            return ee_4p_logistic(theta=theta, X=dose_data, y=resp_data)
+
+        # Optimization procedure
+        estr = MEstimator(psi, init=[0, 2, 1, 10])
+        estr.estimate(solver='lm')
+
+        # Checking mean estimate
+        npt.assert_allclose(estr.theta, comparison_theta, atol=1e-5)
+
+        # Checking variance estimate
+        npt.assert_allclose(np.diag(estr.variance)**0.5, comparison_var, atol=1e-4)
+
+    def test_3pl(self):
+        # Compares against R's drc library:
+        #
+        # library(drc)
+        # library(sandwich)
+        # library(lmtest)
+        # data(ryegrass)
+        # rgll3 = drm(rootl ~ conc, data=ryegrass, fct=LL.3())
+        # coeftest(rgll3, vcov=sandwich)
+        # R optimization from Ritz et al.
+        comparison_theta = np.asarray([3.26336, 2.47033, 7.85543])
+        comparison_var = np.asarray([0.26572, 0.29238, 0.15397])
+
+        d = load_inderjit()
+        dose_data = d[:, 1]
+        resp_data = d[:, 0]
+
+        def psi(theta):
+            return ee_3p_logistic(theta=theta, X=dose_data, y=resp_data,
+                                  lower=0)
+
+        # Optimization procedure
+        estr = MEstimator(psi, init=[2, 1, 10])
+        estr.estimate(solver='lm')
+
+        # Checking mean estimate
+        npt.assert_allclose(estr.theta, comparison_theta, atol=1e-5)
+
+        # Checking variance estimate
+        npt.assert_allclose(np.diag(estr.variance)**0.5, comparison_var, atol=1e-5)
+
+    def test_2pl(self):
+        # Compares against R's drc library:
+        #
+        # library(drc)
+        # library(sandwich)
+        # library(lmtest)
+        #
+        # data(ryegrass)
+        # rgll2 = drm(rootl ~ conc, data=ryegrass, fct=LL.2(upper=8))
+        # coeftest(rgll2, vcov=sandwich)
+        # R optimization from Ritz et al.
+        comparison_theta = np.asarray([3.19946, 2.38220])
+        comparison_var = np.asarray([0.24290, 0.27937])
+
+        d = load_inderjit()
+        dose_data = d[:, 1]
+        resp_data = d[:, 0]
+
+        def psi(theta):
+            return ee_2p_logistic(theta=theta, X=dose_data, y=resp_data,
+                                  lower=0, upper=8)
+
+        # Optimization procedure
+        estr = MEstimator(psi, init=[2, 1])
+        estr.estimate(solver='lm')
+
+        # Checking mean estimate
+        npt.assert_allclose(estr.theta, comparison_theta, atol=1e-5)
+
+        # Checking variance estimate
+        npt.assert_allclose(np.diag(estr.variance)**0.5, comparison_var, atol=1e-5)
+
+    def test_3pl_ed_delta(self):
+        # Compares against R's drc library:
+        #
+        # library(drc)
+        # library(sandwich)
+        # data(ryegrass)
+        # rgll3 = drm(rootl ~ conc, data=ryegrass, fct=LL.3())
+        # ED(rgll3, c(5, 10, 50), interval='delta', vcov=sandwich)
+        # R optimization from Ritz et al.
+        comparison_theta = np.asarray([0.99088, 1.34086, 3.26336])
+        comparison_var = np.asarray([0.12397, 0.13134, 0.26572])
+
+        d = load_inderjit()
+        dose_data = d[:, 1]
+        resp_data = d[:, 0]
+
+        def psi(theta):
+            lower_limit = 0
+            pl3 = ee_3p_logistic(theta=theta, X=dose_data, y=resp_data, lower=lower_limit)
+            ed05 = ee_effective_dose_delta(theta[3], y=resp_data, delta=0.05,
+                                           steepness=theta[0], ed50=theta[1],
+                                           lower=lower_limit, upper=theta[2])
+            ed10 = ee_effective_dose_delta(theta[4], y=resp_data, delta=0.10,
+                                           steepness=theta[0], ed50=theta[1],
+                                           lower=lower_limit, upper=theta[2])
+            ed50 = ee_effective_dose_delta(theta[5], y=resp_data, delta=0.50,
+                                           steepness=theta[0], ed50=theta[1],
+                                           lower=lower_limit, upper=theta[2])
+            return np.vstack((pl3,
+                              ed05,
+                              ed10,
+                              ed50))
+
+        # Optimization procedure
+        estr = MEstimator(psi, init=[2, 1, 10, 1, 1, 2])
+        estr.estimate(solver='lm')
+
+        # Checking mean estimate
+        npt.assert_allclose(estr.theta[-3:], comparison_theta, atol=1e-5)
+
+        # Checking variance estimate
+        npt.assert_allclose(np.diag(estr.variance)[-3:]**0.5, comparison_var, atol=1e-5)
diff --git a/tests/test_ee_measurement.py b/tests/test_ee_measurement.py
new file mode 100644
index 0000000..dd03f5c
--- /dev/null
+++ b/tests/test_ee_measurement.py
@@ -0,0 +1,114 @@
+####################################################################################################################
+# Tests for built-in estimating equations -- measurement error
+####################################################################################################################
+
+import pytest
+import numpy as np
+import numpy.testing as npt
+import pandas as pd
+
+from delicatessen import MEstimator
+from delicatessen.estimating_equations import ee_regression, ee_rogan_gladen
+from delicatessen.utilities import inverse_logit
+
+
+class TestEstimatingEquationsMeasurement:
+
+    @pytest.fixture
+    def cole2023_data(self):
+        # Data from Cole et al. (2023)
+        d = pd.DataFrame()
+        d['Y_star'] = [0, 1] + [0, 1, 0, 1]
+        d['Y'] = [np.nan, np.nan] + [0, 0, 1, 1]
+        d['S'] = [1, 1] + [0, 0, 0, 0]
+        d['n'] = [950-680, 680] + [71, 18, 38, 203]
+        d = pd.DataFrame(np.repeat(d.values, d['n'], axis=0), columns=d.columns)
+        d = d[['Y', 'Y_star', 'S']].copy()
+        d['C'] = 1
+        return d
+
+    @pytest.fixture
+    def cole_roses_data(self):
+        # Data from Cole et al.'s rejoinder (202x)
+        d = pd.DataFrame()
+        d['Y_star'] = [0, 1, 0, 1] + [np.nan, np.nan] + [1, 0, 1, 0]
+        d['Y'] = [np.nan, np.nan, np.nan, np.nan] + [np.nan, np.nan] + [1, 1, 0, 0]
+        d['W'] = [0, 0, 1, 1] + [0, 1] + [np.nan, np.nan, np.nan, np.nan]
+        d['n'] = [266, 67, 400, 267] + [333, 167] + [180, 20, 60, 240]
+        d['S'] = [1, 1, 1, 1] + [2, 2] + [3, 3, 3, 3]
+        d = pd.DataFrame(np.repeat(d.values, d['n'], axis=0), columns=d.columns)
+        d['C'] = 1
+        return d
+
+    def test_rogan_gladen(self, cole2023_data):
+        # Replicate Cole et al. 2023 Rogan-Gladen example as a test
+
+        def psi(theta):
+            return ee_rogan_gladen(theta,
+                                   y=cole2023_data['Y'],
+                                   y_star=cole2023_data['Y_star'],
+                                   r=cole2023_data['S'])
+
+        estr = MEstimator(psi, init=[0.5, 0.5, .75, .75])
+        estr.estimate(solver='lm')
+
+        reference_theta = [0.80231396, 0.71578947, 0.84232365, 0.79775281]
+        reference_covar = [[ 1.56147627e-03, 3.34556962e-04, -6.90781941e-04, 5.59893648e-04],
+                           [ 3.34556962e-04, 2.14142010e-04,  0.,             0.],
+                           [-6.90781941e-04, 0.,              5.51097661e-04, 0.],
+                           [ 5.59893648e-04, 0.,              0.,             1.81284545e-03]]
+
+        # Checking mean estimate
+        npt.assert_allclose(estr.theta, reference_theta,
+                            atol=1e-6)
+
+        # Checking variance estimate
+        npt.assert_allclose(estr.variance, reference_covar,
+                            atol=1e-6)
+
+    def test_rogan_gladen_weights(self, cole_roses_data):
+        # Replicate Cole et al. 202x Rejoinder example as a test
+        dr = cole_roses_data
+        y_no_nan = np.asarray(dr['Y'].fillna(-9))
+        ystar_no_nan = np.asarray(dr['Y_star'].fillna(-9))
+        w_no_nan = np.asarray(dr[['C', 'W']].fillna(-9))
+        s1 = np.where(dr['S'] == 1, 1, 0)
+        s2 = np.where(dr['S'] == 2, 1, 0)
+        s3 = np.where(dr['S'] == 3, 1, 0)
+
+        def psi(theta):
+            param = theta[:4]
+            beta = theta[4:]
+
+            # Inverse odds weights model
+            ee_sm = ee_regression(beta, X=w_no_nan, y=s2,
+                                  model='logistic')
+            ee_sm = ee_sm * (1 - s3)
+            pi_s = inverse_logit(np.dot(w_no_nan, beta))
+            iosw = s1 * pi_s / (1-pi_s) + s3
+
+            # Rogan-Gladen
+            ee_rg = ee_rogan_gladen(param,
+                                    y=y_no_nan,
+                                    y_star=ystar_no_nan,
+                                    r=s1,
+                                    weights=iosw)
+            ee_rg = ee_rg * (1 - s2)
+            return np.vstack([ee_rg, ee_sm])
+
+        estr = MEstimator(psi, init=[0.5, 0.5, .75, .75, 0., 0.])
+        estr.estimate(solver='lm')
+
+        reference_theta = [0.0967144999, 0.267700150, 0.9, 0.8]
+        reference_covar = [[ 1.45149504e-03,  3.88376666e-04, -6.21735965e-05,  6.88217424e-04],
+                           [ 3.88376666e-04,  2.71863653e-04,  0.,              0.],
+                           [-6.21735965e-05,  0.,              4.49999988e-04,  0.],
+                           [ 6.88217424e-04,  0.,              0.,              5.33333316e-04]]
+
+        # Checking mean estimate
+        npt.assert_allclose(estr.theta[0:4], reference_theta,
+                            atol=1e-6)
+
+        # Checking variance estimate
+        npt.assert_allclose(estr.variance[0:4, 0:4], reference_covar,
+                            atol=1e-6)
diff --git a/tests/test_ee_regression.py b/tests/test_ee_regression.py
new file mode 100644
index 0000000..f6bbe96
--- /dev/null
+++ b/tests/test_ee_regression.py
@@ -0,0 +1,1731 @@
+####################################################################################################################
+# Tests for built-in estimating equations -- regression
+####################################################################################################################
+
+import pytest
+import numpy as np
+import numpy.testing as npt
+import pandas as pd
+import statsmodels.api as sm
+import statsmodels.formula.api as smf
+
+from delicatessen import MEstimator
+from delicatessen.estimating_equations import (ee_regression, ee_glm, ee_mlogit,
+                                               ee_robust_regression, ee_ridge_regression,
+                                               ee_lasso_regression, ee_elasticnet_regression,
+                                               ee_additive_regression)
+from delicatessen.utilities import additive_design_matrix
+
+
+@pytest.fixture
+def data_c():
+    d = pd.DataFrame()
+    d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
+    d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
+    d['Y'] = [1, 5, 1, 9, -1, 4, 3, 3, 1, -2, 4, -2, 3, 6, 6, 8, 7, 1, -2, 5]
+    d['F'] = [1, 1, 1, 2, 2, 2, 3, 1, 1, 1, 1, 2, 1, 1, 1, 2, 1, 1, 3, 3]
+    d['w'] = [2, 1, 5, 1, 2, 7, 3, 1, 2, 2, 3, 9, 1, 1, 1, 5, 1, 1, 6, 2]
+    d['I'] = 1
+    return d
+
+
+@pytest.fixture
+def data_b():
+    d = pd.DataFrame()
+    d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
+    d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
+    d['Y'] = [1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 0, 0]
+    d['F'] = [1, 1, 1, 2, 2, 2, 3, 1, 1, 1, 1, 2, 1, 1, 1, 2, 1, 1, 3, 3]
+    d['w'] = [2, 1, 5, 1, 2, 7, 3, 1, 2, 2, 3, 9, 1, 1, 1, 5, 1, 1, 6, 2]
+    d['I'] = 1
+    return d
+
+
+@pytest.fixture
+def data_cp():
+    d = pd.DataFrame()
+    d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
+    d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
+    d['Y'] = [1, 5, 1, 9, 1, 4, 3, 3, 1, 2, 4, 2, 3, 6, 6, 8, 7, 1, 2, 5]
+    d['F'] = [1, 1, 1, 2, 2, 2, 3, 1, 1, 1, 1, 2, 1, 1, 1, 2, 1, 1, 3, 3]
+    d['w'] = [2, 1, 5, 1, 2, 7, 3, 1, 2, 2, 3, 9, 1, 1, 1, 5, 1, 1, 6, 2]
+    d['I'] = 1
+    return d
+
+
+class TestEstimatingEquationsRegression:
+
+    @pytest.fixture
+    def data_m(self):
+        d = pd.DataFrame()
+        d['W0'] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1]
+        d['Y0'] = [1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0]
+        d['Y1'] = [0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0]
+        d['Y2'] = [0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 1, 1]
+        d['offset'] = [.1, -.1, .2, -.2, -.2, .1, .1, .1, -.1, .1, .1, .1, .1, -.1]
+        d['C'] = 1
+        return d
+
+    @pytest.fixture
+    def data_mw(self):
+        d = pd.DataFrame()
+        d['W0'] = [0, 0, 0, 1, 1, 1]
+        d['Y0'] = [1, 0, 0, 1, 0, 0]
+        d['Y1'] = [0, 1, 0, 0, 1, 0]
+        d['Y2'] = [0, 0, 1, 0, 0, 1]
+        d['weight'] = [4, 2, 3, 1, 2, 2]
+        d['C'] = 1
+        return d
+
+    def test_error_regression(self):
+        """Test for error raised when incorrect regression name is provided
+        """
+        n = 100
+        data = pd.DataFrame()
+        data['x1'] = np.random.normal(size=n)
+        data['x2'] = data['x1'] + np.random.normal(scale=0.1, size=n)
+        data['c'] = 1
+        data['y'] = 5 + data['x1'] + np.random.normal(size=n)
+        Xvals = np.asarray(data[['c', 'x1', 'x2']])
+        yvals = np.asarray(data['y'])
+
+        def psi(theta):
+            return ee_regression(theta, X=Xvals, y=yvals, model=748)
+
+        estr = MEstimator(psi, init=[5, 1, 1])
+        with pytest.raises(ValueError, match="The model argument"):
+            estr.estimate(solver='lm')
+
+        def psi(theta):
+            return ee_regression(theta, X=Xvals, y=yvals, model='magic')
+
+        estr = MEstimator(psi, init=[5, 1, 1])
+        with pytest.raises(ValueError, match="Invalid input"):
+            estr.estimate(solver='lm')
+
+    def test_ols(self, data_c):
+        """Tests linear regression with the built-in estimating equation.
+        """
+        d = data_c
+
+        def psi_builtin_regression(theta):
+            return ee_regression(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                                 model='linear')
+
+        estr = MEstimator(psi_builtin_regression, init=[0., 0., 0.])
+        estr.estimate()
+
+        # Statsmodels function equivalent
+        glm = smf.glm("Y ~ X + Z", d).fit(cov_type="HC1")
+
+        # Checking mean estimate
+        npt.assert_allclose(estr.theta,
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(estr.variance,
+                            np.asarray(glm.cov_params()),
+                            atol=1e-6)
+
+        # Checking confidence interval estimates
+        npt.assert_allclose(estr.confidence_intervals(),
+                            np.asarray(glm.conf_int()),
+                            atol=1e-6)
+
+    def test_ols_offset(self, data_c):
+        """Tests linear regression with the built-in estimating equation and an offset term.
+        """
+        d = data_c
+
+        def psi_builtin_regression(theta):
+            return ee_regression(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                                 offset=d['F'], model='linear')
+
+        mpee = MEstimator(psi_builtin_regression, init=[0.1, 0.1, 0.1])
+        mpee.estimate()
+
+        # Statsmodels function equivalent
+        glm = smf.glm("Y ~ X + Z", d, offset=d['F']).fit(cov_type="HC1")
+
+        # Checking mean estimate
+        npt.assert_allclose(mpee.theta,
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mpee.variance,
+                            np.asarray(glm.cov_params()),
+                            atol=1e-6)
+
+        # Checking confidence interval estimates
+        npt.assert_allclose(mpee.confidence_intervals(),
+                            np.asarray(glm.conf_int()),
+                            atol=1e-6)
+
+    def test_wls(self, data_c):
+        """Tests weighted linear regression
+        """
+        d = data_c
+
+        def psi_regression(theta):
+            return ee_regression(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                                 model='linear', weights=d['w'])
+
+        mestimator = MEstimator(psi_regression, init=[0.1, 0.1, 0.1])
+        mestimator.estimate()
+
+        # Comparing to statsmodels GLM (with robust covariance)
+        glm = smf.glm("Y ~ X + Z", d, freq_weights=d['w']).fit(cov_type="cluster",
+                                                               cov_kwds={"groups": d.index, "use_correction": False})
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta,
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mestimator.variance,
+                            np.asarray(glm.cov_params()),
+                            atol=1e-6)
+
+        # Checking confidence interval estimates
+        npt.assert_allclose(mestimator.confidence_intervals(),
+                            np.asarray(glm.conf_int()),
+                            atol=1e-6)
+
+    def test_wls_offset(self, data_c):
+        """Tests weighted linear regression with an offset term
+        """
+        d = data_c
+
+        def psi_regression(theta):
+            return ee_regression(theta,
+                                 X=d[['I', 'X', 'Z']], y=d['Y'],
+                                 model='linear', weights=d['w'], offset=d['F'])
+
+        mestimator = MEstimator(psi_regression, init=[0.1, 0.1, 0.1])
+        mestimator.estimate()
+
+        # Comparing to statsmodels GLM (with robust covariance)
+        glm = smf.glm("Y ~ X + Z", d, freq_weights=d['w'],
+                      offset=d['F']).fit(cov_type="cluster",
+                                         cov_kwds={"groups": d.index, "use_correction": False})
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta,
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mestimator.variance,
+                            np.asarray(glm.cov_params()),
+                            atol=1e-6)
+
+        # Checking confidence interval estimates
+        npt.assert_allclose(mestimator.confidence_intervals(),
+                            np.asarray(glm.conf_int()),
+                            atol=1e-6)
+
+    def test_logistic(self, data_b):
+        d = data_b
+
+        def psi_builtin_regression(theta):
+            return ee_regression(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                                 model='logistic')
+
+        mpee = MEstimator(psi_builtin_regression, init=[0., 0., 0.])
+        mpee.estimate()
+
+        # Comparing to statsmodels GLM (with robust covariance)
+        glm = smf.glm("Y ~ X + Z", d, family=sm.families.Binomial()).fit(cov_type="HC1")
+
+        # Checking mean estimate
+        npt.assert_allclose(mpee.theta,
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mpee.variance,
+                            np.asarray(glm.cov_params()),
+                            atol=1e-6)
+
+        # Checking confidence interval estimates
+        npt.assert_allclose(mpee.confidence_intervals(),
+                            np.asarray(glm.conf_int()),
+                            atol=1e-6)
+
+    def test_logistic_offset(self, data_b):
+        d = data_b
+
+        def psi_builtin_regression(theta):
+            return ee_regression(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                                 model='logistic', offset=d['F'])
+
+        mpee = MEstimator(psi_builtin_regression, init=[0., 0., 0.])
+        mpee.estimate()
+
+        # Comparing to statsmodels GLM (with robust covariance)
+        glm = smf.glm("Y ~ X + Z", d, family=sm.families.Binomial(), offset=d['F']).fit(cov_type="HC1")
+
+        # Checking mean estimate
+        npt.assert_allclose(mpee.theta,
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mpee.variance,
+                            np.asarray(glm.cov_params()),
+                            atol=1e-5)
+
+        # Checking confidence interval estimates
+        npt.assert_allclose(mpee.confidence_intervals(),
+                            np.asarray(glm.conf_int()),
+                            atol=1e-5)
+
+    def test_weighted_logistic(self, data_b):
+        d = data_b
+
+        def psi_regression(theta):
+            return ee_regression(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                                 model='logistic', weights=d['w'])
+
+        mestimator = MEstimator(psi_regression, init=[0., 2., -1.])
+        mestimator.estimate(solver='lm')
+
+        # Comparing to statsmodels GLM (with robust covariance)
+        glm = smf.glm("Y ~ X + Z", d, freq_weights=d['w'],
+                      family=sm.families.Binomial()).fit(cov_type="cluster",
+                                                         cov_kwds={"groups": d.index,
+                                                                   "use_correction": False})
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta,
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mestimator.variance,
+                            np.asarray(glm.cov_params()),
+                            atol=1e-6)
+
+        # Checking confidence interval estimates
+        npt.assert_allclose(mestimator.confidence_intervals(),
+                            np.asarray(glm.conf_int()),
+                            atol=1e-6)
+
+    def test_poisson(self, data_cp):
+        d = data_cp
+
+        def psi_regression(theta):
+            return ee_regression(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                                 model='poisson')
+
+        mestimator = MEstimator(psi_regression, init=[0., 0., 0.])
+        mestimator.estimate(solver='lm')
+
+        # Comparing to statsmodels GLM (with robust covariance)
+        glm = smf.glm("Y ~ X + Z", d, family=sm.families.Poisson()).fit(cov_type="HC1")
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta,
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mestimator.variance,
+                            np.asarray(glm.cov_params()),
+                            atol=1e-6)
+
+        # Checking confidence interval estimates
+        npt.assert_allclose(mestimator.confidence_intervals(),
+                            np.asarray(glm.conf_int()),
+                            atol=1e-6)
+
+    def test_poisson_offset(self, data_cp):
+        d = data_cp
+
+        def psi_regression(theta):
+            return ee_regression(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                                 model='poisson', offset=d['F'])
+
+        mestimator = MEstimator(psi_regression, init=[0., 0., 0.])
+        mestimator.estimate(solver='lm')
+
+        # Comparing to statsmodels GLM (with robust covariance)
+        glm = smf.glm("Y ~ X + Z", d, offset=d['F'], family=sm.families.Poisson()).fit(cov_type="HC1")
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta,
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mestimator.variance,
+                            np.asarray(glm.cov_params()),
+                            atol=1e-6)
+
+        # Checking confidence interval estimates
+        npt.assert_allclose(mestimator.confidence_intervals(),
+                            np.asarray(glm.conf_int()),
+                            atol=1e-6)
+
+    def test_weighted_poisson(self, data_cp):
+        d = data_cp
+
+        def psi_regression(theta):
+            return ee_regression(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                                 model='poisson', weights=d['w'])
+
+        mestimator = MEstimator(psi_regression, init=[0., 0., 0.])
+        mestimator.estimate(solver='lm')
+
+        # Comparing to statsmodels GLM (with robust covariance)
+        glm = smf.glm("Y ~ X + Z", d, freq_weights=d['w'],
+                      family=sm.families.Poisson()).fit(cov_type="cluster",
+                                                        cov_kwds={"groups": d.index,
+                                                                  "use_correction": False})
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta,
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mestimator.variance,
+                            np.asarray(glm.cov_params()),
+                            atol=1e-6)
+
+        # Checking confidence interval estimates
+        npt.assert_allclose(mestimator.confidence_intervals(),
+                            np.asarray(glm.conf_int()),
+                            atol=1e-6)
+
+    def test_mlogit(self, data_m):
+        # Setup data
+        d = data_m
+        y = np.asarray(d[['Y0', 'Y1', 'Y2']])
+        X = np.asarray(d[['C', 'W0']])
+
+        # M-estimator
+        def psi(theta):
+            return ee_mlogit(theta=theta, X=X, y=y)
+
+        estr = MEstimator(psi, init=[0., 0., 0., 0.])
+        estr.estimate(solver='lm')
+
+        # Statsmodels as the reference
+        model = sm.MNLogit(y, X)
+        fm = model.fit(cov_type="HC1")
+        ref_params = list(fm.params[:, 0]) + list(fm.params[:, 1])
+
+        # Checking mean estimate
+        npt.assert_allclose(estr.theta,
+                            ref_params,
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(estr.variance,
+                            np.asarray(fm.cov_params()),
+                            atol=1e-5)
+
+    def test_mlogit_weights(self, data_m, data_mw):
+        # Setup data
+        d = data_m
+        y = np.asarray(d[['Y0', 'Y1', 'Y2']])
+        X = np.asarray(d[['C', 'W0']])
+
+        # M-estimator for reference
+        def psi(theta):
+            return ee_mlogit(theta=theta, X=X, y=y)
+
+        estr1 = MEstimator(psi, init=[0., 0., 0., 0.])
+        estr1.estimate(solver='lm')
+        ref_par = np.asarray(estr1.theta)
+
+        # Setup data
+        d = data_mw
+        y = np.asarray(d[['Y0', 'Y1', 'Y2']])
+        X = np.asarray(d[['C', 'W0']])
+        w = np.asarray(d['weight'])
+
+        # M-estimator for reference
+        def psi(theta):
+            return ee_mlogit(theta=theta, X=X, y=y, weights=w)
+
+        estr2 = MEstimator(psi, init=[0., 0., 0., 0.])
+        estr2.estimate(solver='lm')
+        wgt_par = np.asarray(estr2.theta)
+
+        # Checking mean estimate
+        npt.assert_allclose(wgt_par,
+                            ref_par,
+                            atol=1e-6)
+
+    def test_mlogit_offset(self, data_m):
+        # Setup data
+        d = data_m
+        y = np.asarray(d[['Y0', 'Y1', 'Y2']])
+        X = np.asarray(d[['C', 'W0']])
+
+        # M-estimator
+        def psi(theta):
+            return ee_mlogit(theta=theta, X=X, y=y,
+                             offset=d['offset'])
+
+        estr = MEstimator(psi, init=[0., 0., 0., 0.])
+        estr.estimate(solver='lm', tolerance=1e-12)
+
+        # SAS as the reference
+        # NOTE: I cannot find a variance reference. SAS doesn't offer it for proc logistic and genmod does not support
+        #       unranked multinomial logistic. R's mlogit doesn't allow the specification of the offset. So, I can only
+        #       compare coefficients here.
+        # SAS code used for the comparison:
+        # data dat;
+        #     input w y offset weight;
+        #     datalines;
+        # 0 1   0.1 1
+        # 0 1  -0.1 1
+        # 0 1   0.2 2
+        # 0 1  -0.2 1
+        # 0 2  -0.2 3
+        # 0 2   0.1 4
+        # 0 3   0.1 2
+        # 0 3   0.1 1
+        # 0 3  -0.1 1
+        # 1 1   0.1 1
+        # 1 2   0.1 1
+        # 1 2   0.1 1
+        # 1 3   0.1 1
+        # 1 3  -0.1 5
+        # ;
+        # run;
+        # proc logistic data = dat;
+        # 	class y (ref = "1") / param = ref;
+        # 	model y = w / link = glogit offset = offset gconv=1e-12;
+        # 	ods output ParameterEstimates=or;
+        # run;
+        # proc print data=or label;
+        #     format _numeric_ 12.10;
+        #     var _numeric_;
+        #     title "Odds Ratio Estimates";
+        # run;
+        ref_params = [-0.6920875177, 1.3271570373, -0.2866227784, 0.9216939568]
+
+        # Checking mean estimate
+        npt.assert_allclose(estr.theta,
+                            ref_params,
+                            atol=5e-5)
+
+
+class TestEstimatingEquationsRegressionRobust:
+
+    @pytest.fixture
+    def data_r(self):
+        d = pd.DataFrame()
+        d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
+        d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
+        d['Y'] = [1, 5, 1, 9, -1, 4, 3, 3, 1, -2, 4, -2, 3, 6, 6, 8, 7, 1, -2, 15]
+        d['F'] = [1, 1, 1, 2, 2, 2, 3, 1, 1, 1, 1, 2, 1, 1, 1, 2, 1, 1, 3, 3]
+        d['w'] = [2, 1, 5, 1, 2, 7, 3, 1, 2, 2, 3, 9, 1, 1, 1, 5, 1, 1, 6, 2]
+        d['I'] = 1
+        return d
+
+    def test_error_robust(self, data_r):
+        d = data_r
+        Xvals = np.asarray(d[['I', 'X', 'Z']])
+        yvals = np.asarray(d['Y'])
+
+        def psi(theta):
+            return ee_robust_regression(theta, X=Xvals, y=yvals, model='logistic', k=5)
+
+        estr = MEstimator(psi, init=[5, 1, 1])
+        with pytest.raises(ValueError, match="only supports linear"):
+            estr.estimate(solver='lm')
+
+    def test_robust_eq_to_linear(self, data_r):
+        d = data_r
+        Xvals = np.asarray(d[['I', 'X', 'Z']])
+        yvals = np.asarray(d['Y'])
+
+        def psi_linear(theta):
+            return ee_regression(theta, X=Xvals, y=yvals,
+                                 model='linear')
+
+        linear = MEstimator(psi_linear, init=[0., 0., 0.])
+        linear.estimate(solver='lm')
+
+        def psi_robust(theta):
+            return ee_robust_regression(theta, X=Xvals, y=yvals,
+                                        model='linear', k=200)
+
+        robust = MEstimator(psi_robust, init=[0., 0., 0.])
+        robust.estimate(solver='lm')
+
+        # Checking mean estimate
+        npt.assert_allclose(linear.theta,
+                            robust.theta,
+                            atol=1e-6)
+
+    def test_robust_linear_offset(self, data_r):
+        d = data_r
+        Xvals = np.asarray(d[['I', 'X', 'Z']])
+        yvals = np.asarray(d['Y'])
+        offset = np.asarray(d['F'])
+
+        def psi_linear(theta):
+            return ee_regression(theta, X=Xvals, y=yvals,
+                                 model='linear', offset=offset)
+
+        linear = MEstimator(psi_linear, init=[0., 0., 0.])
+        linear.estimate(solver='lm')
+
+        def psi_robust(theta):
+            return ee_robust_regression(theta, X=Xvals, y=yvals,
+                                        model='linear', k=200, offset=offset)
+
+        robust = MEstimator(psi_robust, init=[0., 0., 0.])
+        robust.estimate(solver='lm')
+
+        # Checking mean estimate
+        npt.assert_allclose(linear.theta,
+                            robust.theta,
+                            atol=1e-6)
+
+
+class TestEstimatingEquationsRegressionPenalty:
+
+    def test_error_penalized(self, data_c):
+        d = data_c
+        Xvals = np.asarray(d[['I', 'X', 'Z']])
+        yvals = np.asarray(d['Y'])
+
+        def psi(theta):
+            return ee_ridge_regression(theta, X=Xvals, y=yvals, model='linear',
+                                       penalty=[0.5, 5.], weights=None)
+
+        estr = MEstimator(psi, init=[5, 1, 1])
+        with pytest.raises(ValueError, match="The penalty term must"):
+            estr.estimate(solver='lm')
+
+    def test_ridge_ols(self, data_c):
+        d = data_c
+        Xvals = np.asarray(d[['I', 'X', 'Z']])
+        yvals = np.asarray(d['Y'])
+
+        # Penalty of 0.5
+        def psi(theta):
+            return ee_ridge_regression(theta, X=Xvals, y=yvals, model='linear', penalty=0.5, weights=None)
+
+        estr = MEstimator(psi, init=[5, 1, 1])
+        estr.estimate(solver='lm')
+
+        ridge = sm.OLS(yvals, Xvals).fit_regularized(L1_wt=0., alpha=0.5 / Xvals.shape[0])
+
+        # Checking mean estimate
+        npt.assert_allclose(estr.theta,
+                            np.asarray(ridge.params),
+                            atol=1e-6)
+
+        # Penalty of 5.0
+        def psi(theta):
+            return ee_ridge_regression(theta, X=Xvals, y=yvals, model='linear', penalty=5.0, weights=None)
+
+        estr = MEstimator(psi, init=[5, 1, 1])
+        estr.estimate(solver='lm')
+        ridge = sm.OLS(yvals, Xvals).fit_regularized(L1_wt=0., alpha=5. / Xvals.shape[0])
+
+        # Checking mean estimate
+        npt.assert_allclose(estr.theta,
+                            np.asarray(ridge.params),
+                            atol=1e-6)
+
+        # Testing array of penalty terms
+        def psi(theta):
+            return ee_ridge_regression(theta, X=Xvals, y=yvals, model='linear', penalty=[0., 5., 2.], weights=None)
+
+        estr = MEstimator(psi, init=[5, 1, 1])
+        estr.estimate(solver='lm')
+
+        ridge = sm.OLS(yvals, Xvals).fit_regularized(L1_wt=0., alpha=np.array([0., 5., 2.]) / Xvals.shape[0])
+
+        # Checking mean estimate
+        npt.assert_allclose(estr.theta,
+                            np.asarray(ridge.params),
+                            atol=1e-6)
+
+    def test_ridge_ols_offset(self, data_c):
+        d = data_c
+        Xvals = np.asarray(d[['I', 'X', 'Z']])
+        yvals = np.asarray(d['Y'])
+
+        # Penalty of 0.5
+        def psi(theta):
+            return ee_ridge_regression(theta, X=Xvals, y=yvals, model='linear',
+                                       penalty=[0., 5., 2.], weights=None, offset=d['F'])
+
+        estr = MEstimator(psi, init=[5, 1, 1])
+        estr.estimate(solver='lm')
+        ridge = sm.OLS(yvals - d['F'], Xvals,).fit_regularized(L1_wt=0.,
+                                                               alpha=np.array([0., 5., 2.]) / Xvals.shape[0])
+
+        # Checking mean estimate
+        npt.assert_allclose(estr.theta,
+                            np.asarray(ridge.params),
+                            atol=1e-6)
+
+    def test_ridge_wls(self, data_c):
+        d = data_c
+        Xvals = np.asarray(d[['I', 'X', 'Z']])
+        yvals = np.asarray(d['Y'])
+        weights = np.asarray(d['w'])
+
+        # Penalty of 0.5
+        def psi(theta):
+            return ee_ridge_regression(theta, X=Xvals, y=yvals, model='linear', penalty=0.5, weights=weights)
+
+        estr = MEstimator(psi, init=[5, 1, 1])
+        estr.estimate(solver='lm')
+        wridge = sm.WLS(yvals, Xvals, weights=weights).fit_regularized(L1_wt=0., alpha=0.5 / Xvals.shape[0])
+
+        # Checking mean estimate
+        npt.assert_allclose(estr.theta,
+                            np.asarray(wridge.params),
+                            atol=1e-6)
+
+        # Penalty of 5.0
+        def psi(theta):
+            return ee_ridge_regression(theta, X=Xvals, y=yvals, model='linear', penalty=5.0, weights=weights)
+
+        estr = MEstimator(psi, init=[5, 1, 1])
+        estr.estimate(solver='lm')
+        wridge = sm.WLS(yvals, Xvals, weights=weights).fit_regularized(L1_wt=0., alpha=5. / Xvals.shape[0])
+
+        # Checking mean estimate
+        npt.assert_allclose(estr.theta,
+                            np.asarray(wridge.params),
+                            atol=1e-6)
+
+        # Testing array of penalty terms
+        def psi(theta):
+            return ee_ridge_regression(theta, X=Xvals, y=yvals, model='linear', penalty=[0., 5., 2.], weights=weights)
+
+        estr = MEstimator(psi, init=[5, 1, 1])
+        estr.estimate(solver='lm')
+        wridge = sm.WLS(yvals, Xvals, weights=weights).fit_regularized(L1_wt=0.,
+                                                                       alpha=np.array([0., 5., 2.]) / Xvals.shape[0])
+
+        # Checking mean estimate
+        npt.assert_allclose(estr.theta,
+                            np.asarray(wridge.params),
+                            atol=1e-6)
+
+    def test_additive_ols(self, data_c):
+        d = data_c
+        Xvals = np.asarray(d[['I', 'X', 'Z']])
+        yvals = np.asarray(d['Y'])
+        spec = [None, {"knots": [-1, 0, 1], "penalty": 15}, None]
+
+        # Testing array of penalty terms
+        def psi(theta):
+            return ee_additive_regression(theta, X=Xvals, y=yvals, model='linear', specifications=spec)
+
+        estr = MEstimator(psi, init=[5, 1, 0, 0, 0])
+        estr.estimate(solver='lm')
+
+        design, penalty = additive_design_matrix(X=Xvals, specifications=spec, return_penalty=True)
+        ridge = sm.OLS(yvals, design).fit_regularized(L1_wt=0., alpha=np.array(penalty)/Xvals.shape[0])
+
+        # Checking mean estimate
+        npt.assert_allclose(estr.theta,
+                            np.asarray(ridge.params),
+                            atol=1e-6)
+
+    def test_additive_wls(self, data_c):
+        d = data_c
+        Xvals = np.asarray(d[['I', 'X', 'Z']])
+        yvals = np.asarray(d['Y'])
+        weights = np.asarray(d['w'])
+        spec = [None, {"knots": [-1, 0, 1], "penalty": 15}, None]
+
+        # Testing array of penalty terms
+        def psi(theta):
+            return ee_additive_regression(theta, X=Xvals, y=yvals, model='linear',
+                                          specifications=spec, weights=weights)
+
+        estr = MEstimator(psi, init=[5, 1, 0, 0, 0])
+        estr.estimate(solver='lm')
+
+        design, penalty = additive_design_matrix(X=Xvals, specifications=spec, return_penalty=True)
+        ridge = sm.WLS(yvals, design, weights=weights).fit_regularized(L1_wt=0.,
+                                                                       alpha=np.array(penalty)/Xvals.shape[0])
+
+        # Checking mean estimate
+        npt.assert_allclose(estr.theta,
+                            np.asarray(ridge.params),
+                            atol=1e-6)
+
+    def test_lasso_ols(self, data_c):
+        """Tests linear regression with the built-in estimating equation.
+        """
+        d = data_c
+
+        def psi_builtin_regression(theta):
+            return ee_lasso_regression(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                                       model='linear', penalty=1.)
+
+        estr = MEstimator(psi_builtin_regression, init=[0., 0., 0.])
+        estr.estimate()
+
+    def test_ridge_logistic(self, data_b):
+        d = data_b
+        Xvals = np.asarray(d[['I', 'X', 'Z']])
+        yvals = np.asarray(d['Y'])
+
+        def psi_regression(theta):
+            return ee_ridge_regression(theta, X=Xvals, y=yvals,
+                                       model='logistic', penalty=0.5, weights=None)
+
+        mestimator = MEstimator(psi_regression, init=[0., 0., 0.])
+        mestimator.estimate(solver='lm', tolerance=1e-12)
+
+        f = sm.families.Binomial()
+        lgt = sm.GLM(yvals, Xvals, family=f).fit_regularized(L1_wt=0., alpha=0.5 / Xvals.shape[0], tol=1e-12)
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta,
+                            np.asarray(lgt.params),
+                            atol=1e-5)
+
+        def psi_regression(theta):
+            return ee_ridge_regression(theta, X=Xvals, y=yvals,
+                                       model='logistic', penalty=15., weights=None)
+
+        mestimator = MEstimator(psi_regression, init=[0., 0., 0.])
+        mestimator.estimate(solver='hybr', tolerance=1e-12)
+
+        f = sm.families.Binomial()
+        lgt = sm.GLM(yvals, Xvals, family=f).fit_regularized(L1_wt=0., alpha=15. / Xvals.shape[0], tol=1e-12)
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta,
+                            np.asarray(lgt.params),
+                            atol=5e-4)
+
+        def psi_regression(theta):
+            return ee_ridge_regression(theta,
+                                       X=Xvals, y=yvals,
+                                       model='logistic', penalty=[0., 5., 2.], weights=None)
+
+        mestimator = MEstimator(psi_regression, init=[0., 0., 0.])
+        mestimator.estimate(solver='hybr', tolerance=1e-12)
+
+        f = sm.families.Binomial()
+        lgt = sm.GLM(yvals, Xvals, family=f).fit_regularized(L1_wt=0., alpha=np.array([0., 5., 2.]) / Xvals.shape[0])
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta,
+                            np.asarray(lgt.params),
+                            atol=5e-4)
+
+    def test_additive_logistic(self, data_b):
+        d = data_b
+        Xvals = np.asarray(d[['I', 'X', 'Z']])
+        yvals = np.asarray(d['Y'])
+        spec = [None, {"knots": [-1, 0, 1], "penalty": 15}, None]
+
+        def psi_regression(theta):
+            return ee_additive_regression(theta, X=Xvals, y=yvals,
+                                          model='logistic',
+                                          specifications=spec)
+
+        mestimator = MEstimator(psi_regression, init=[0., 2., 0., 0., 0.])
+        mestimator.estimate(solver='lm', tolerance=1e-12)
+
+        design, penalty = additive_design_matrix(X=Xvals, specifications=spec, return_penalty=True)
+        f = sm.families.Binomial()
+        lgt = sm.GLM(yvals, design, family=f).fit_regularized(L1_wt=0., alpha=np.array(penalty)/Xvals.shape[0],
+                                                              tol=1e-12)
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta,
+                            np.asarray(lgt.params),
+                            atol=1e-3)
+
+    def test_ridge_poisson(self, data_cp):
+        d = data_cp
+        Xvals = np.asarray(d[['I', 'X', 'Z']])
+        yvals = np.asarray(d['Y'])
+
+        def psi_regression(theta):
+            return ee_ridge_regression(theta, X=Xvals, y=yvals,
+                                       model='poisson', penalty=0.5, weights=None)
+
+        mestimator = MEstimator(psi_regression, init=[0., 0., 0.])
+        mestimator.estimate(solver='lm', tolerance=1e-12)
+
+        f = sm.families.Poisson()
+        lgt = sm.GLM(yvals, Xvals, family=f).fit_regularized(L1_wt=0., alpha=0.5 / Xvals.shape[0])
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta,
+                            np.asarray(lgt.params),
+                            atol=1e-5)
+
+        def psi_regression(theta):
+            return ee_ridge_regression(theta, X=Xvals, y=yvals,
+                                       model='poisson', penalty=2.5, weights=None)
+
+        mestimator = MEstimator(psi_regression, init=[0., 0., 0.])
+        mestimator.estimate(solver='lm', tolerance=1e-12)
+
+        lgt = sm.GLM(yvals, Xvals, family=f).fit_regularized(L1_wt=0., alpha=2.5 / Xvals.shape[0])
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta,
+                            np.asarray(lgt.params),
+                            atol=1e-5)
+
+        def psi_regression(theta):
+            return ee_ridge_regression(theta, X=Xvals, y=yvals,
+                                       model='poisson', penalty=[0., 5., 2.5], weights=None)
+
+        mestimator = MEstimator(psi_regression, init=[0., 0., 0.])
+        mestimator.estimate(solver='lm', tolerance=1e-12)
+
+        lgt = sm.GLM(yvals, Xvals, family=f).fit_regularized(L1_wt=0., alpha=np.asarray([0., 5., 2.5]) / Xvals.shape[0])
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta,
+                            np.asarray(lgt.params),
+                            atol=1e-5)
+
+    def test_additive_poisson(self, data_cp):
+        d = data_cp
+        Xvals = np.asarray(d[['I', 'X', 'Z']])
+        yvals = np.asarray(d['Y'])
+        spec = [None, {"knots": [-1, 0, 1], "penalty": 3}, None]
+
+        def psi_regression(theta):
+            return ee_additive_regression(theta, X=Xvals, y=yvals,
+                                          model='poisson',
+                                          specifications=spec)
+
+        mestimator = MEstimator(psi_regression, init=[0., 2., 0., 0., 0.])
+        mestimator.estimate(solver='lm')
+
+        design, penalty = additive_design_matrix(X=Xvals, specifications=spec, return_penalty=True)
+        f = sm.families.Poisson()
+        lgt = sm.GLM(yvals, design, family=f).fit_regularized(L1_wt=0., alpha=np.array(penalty)/Xvals.shape[0])
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta,
+                            np.asarray(lgt.params),
+                            atol=1e-5)
+
+    def test_elasticnet(self, data_c):
+        d = data_c
+        Xvals = np.asarray(d[['I', 'X', 'Z']])
+        yvals = np.asarray(d['Y'])
+
+        def psi_ridge(theta):
+            return ee_ridge_regression(theta, X=Xvals, y=yvals,
+                                       model='linear',
+                                       penalty=[0., 5., 2.])
+
+        ridge = MEstimator(psi_ridge, init=[0., 0., 0.])
+        ridge.estimate(solver='lm')
+
+        def psi_elastic(theta):
+            return ee_elasticnet_regression(theta, X=Xvals, y=yvals,
+                                            model='linear',
+                                            penalty=[0., 5., 2.], ratio=0)
+
+        elastic = MEstimator(psi_elastic, init=[0., 0., 0.])
+        elastic.estimate(solver='lm')
+
+        # Checking mean estimate
+        npt.assert_allclose(ridge.theta,
+                            elastic.theta,
+                            atol=1e-5)
+
+    def test_elasticnet_offset(self, data_c):
+        d = data_c
+        Xvals = np.asarray(d[['I', 'X', 'Z']])
+        yvals = np.asarray(d['Y'])
+
+        def psi_ridge(theta):
+            return ee_ridge_regression(theta, X=Xvals, y=yvals,
+                                       model='linear', penalty=[0., 5., 2.], offset=d['F'])
+
+        ridge = MEstimator(psi_ridge, init=[0., 0., 0.])
+        ridge.estimate(solver='lm')
+
+        def psi_elastic(theta):
+            return ee_elasticnet_regression(theta, X=Xvals, y=yvals,
+                                            model='linear', penalty=[0., 5., 2.], ratio=0, offset=d['F'])
+
+        elastic = MEstimator(psi_elastic, init=[0., 0., 0.])
+        elastic.estimate(solver='lm')
+
+        # Checking mean estimate
+        npt.assert_allclose(ridge.theta,
+                            elastic.theta,
+                            atol=1e-5)
+
+
+class TestEstimatingEquationsGLM:
+
+    @pytest.fixture
+    def data_cpp(self):
+        d = pd.DataFrame()
+        d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
+        d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
+        d['Y'] = [0, 0, 0, 0, 0, 15, 15, 25, 25, 45, 0, 0, 0, 0, 15, 15, 15, 25, 25, 35]
+        d['I'] = 1
+        return d
+
+    @pytest.fixture
+    def data_g(self):
+        # Example data comes from R's MASS library
+        d = pd.DataFrame()
+        d['X'] = np.log([5, 10, 15, 20, 30, 40, 60, 80, 100])
+        d['Y'] = [118, 58, 42, 35, 27, 25, 21, 19, 18]
+        d['w'] = [1, 2, 1, 3, 1, 6, 1, 2, 3]
+        d['I'] = 1
+        return d
+
+    def test_glm_normal_identity(self, data_c):
+        d = data_c
+
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                          distribution='normal', link='identity')
+
+        mestr = MEstimator(psi, init=[0., 0., 0.])
+        mestr.estimate(solver='lm')
+
+        fam = sm.families.Gaussian(sm.families.links.identity())
+        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1")
+
+        # Checking mean estimate
+        npt.assert_allclose(mestr.theta,
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mestr.variance,
+                            np.asarray(glm.cov_params()),
+                            atol=1e-6)
+
+    def test_glm_normal_log(self, data_c):
+        d = data_c
+
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                          distribution='normal', link='log')
+
+        mestr = MEstimator(psi, init=[2., 0., 0.])
+        mestr.estimate(solver='lm', maxiter=5000)
+
+        fam = sm.families.Gaussian(sm.families.links.Log())
+        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1")
+
+        # Checking mean estimate
+        npt.assert_allclose(mestr.theta,
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mestr.variance,
+                            np.asarray(glm.cov_params()),
+                            atol=1e-6)
+
+    def test_glm_poisson_identity(self, data_cp):
+        d = data_cp
+
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                          distribution='poisson', link='identity')
+
+        mestr = MEstimator(psi, init=[1., 0., 0.])
+        mestr.estimate(solver='lm', maxiter=5000)
+
+        fam = sm.families.Poisson(sm.families.links.identity())
+        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1")
+
+        # Checking mean estimate
+        npt.assert_allclose(mestr.theta,
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mestr.variance,
+                            np.asarray(glm.cov_params()),
+                            atol=1e-6)
+
+    def test_glm_poisson_log(self, data_cp):
+        d = data_cp
+
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                          distribution='poisson', link='log')
+
+        mestr = MEstimator(psi, init=[0., 0., 0.])
+        mestr.estimate(solver='lm', maxiter=5000)
+
+        fam = sm.families.Poisson(sm.families.links.Log())
+        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1")
+
+        # Checking mean estimate
+        npt.assert_allclose(mestr.theta,
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mestr.variance,
+                            np.asarray(glm.cov_params()),
+                            atol=1e-6)
+
+    def test_glm_poisson_sqrt(self, data_cp):
+        d = data_cp
+
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                          distribution='poisson', link='sqrt')
+
+        mestr = MEstimator(psi, init=[2., 0., 0.])
+        mestr.estimate(solver='lm', maxiter=5000)
+
+        fam = sm.families.Poisson(sm.families.links.Sqrt())
+        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1")
+
+        # Checking mean estimate
+        npt.assert_allclose(mestr.theta,
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mestr.variance,
+                            np.asarray(glm.cov_params()),
+                            atol=1e-6)
+
+    def test_glm_binomial_logit(self, data_b):
+        d = data_b
+
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                          distribution='binomial', link='logit')
+
+        mestr = MEstimator(psi, init=[0., 0., 0.])
+        mestr.estimate(solver='lm', maxiter=5000)
+
+        fam = sm.families.Binomial(sm.families.links.Logit())
+        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1")
+
+        # Checking mean estimate
+        npt.assert_allclose(mestr.theta,
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mestr.variance,
+                            np.asarray(glm.cov_params()),
+                            atol=1e-6)
+
+    def test_glm_binomial_log(self, data_b):
+        d = data_b
+
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                          distribution='binomial', link='log')
+
+        mestr = MEstimator(psi, init=[-.9, 0., 0.])
+        mestr.estimate(solver='lm', maxiter=5000)
+
+        fam = sm.families.Binomial(sm.families.links.Log())
+        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1", tol=1e-12)
+
+        # Checking mean estimate
+        npt.assert_allclose(mestr.theta,
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mestr.variance,
+                            np.asarray(glm.cov_params()),
+                            atol=1e-6)
+
+    def test_glm_binomial_identity(self, data_b):
+        d = data_b
+
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                          distribution='binomial', link='identity')
+
+        mestr = MEstimator(psi, init=[.2, 0., 0.])
+        mestr.estimate(solver='lm', maxiter=5000)
+
+        fam = sm.families.Binomial(sm.families.links.identity())
+        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1", tol=1e-12)
+
+        # Checking mean estimate
+        npt.assert_allclose(mestr.theta,
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mestr.variance,
+                            np.asarray(glm.cov_params()),
+                            atol=1e-6)
+
+    def test_glm_binomial_probit(self, data_b):
+        d = data_b
+
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                          distribution='binomial', link='probit')
+
+        mestr = MEstimator(psi, init=[0., 0., 0.])
+        mestr.estimate(solver='lm', maxiter=5000)
+
+        fam = sm.families.Binomial(sm.families.links.Probit())
+        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1", tol=1e-12)
+
+        # Checking mean estimate
+        npt.assert_allclose(mestr.theta,
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mestr.variance,
+                            np.asarray(glm.cov_params()),
+                            atol=1e-6)
+
+    def test_glm_binomial_cauchy(self, data_b):
+        d = data_b
+
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                          distribution='binomial', link='cauchy')
+
+        mestr = MEstimator(psi, init=[0., 0., 0.])
+        mestr.estimate(solver='lm', maxiter=5000)
+
+        fam = sm.families.Binomial(sm.families.links.Cauchy())
+        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1", tol=1e-12)
+
+        # Checking mean estimate
+        npt.assert_allclose(mestr.theta,
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mestr.variance,
+                            np.asarray(glm.cov_params()),
+                            atol=1e-6)
+
+    def test_glm_binomial_cloglog(self, data_b):
+        d = data_b
+
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                          distribution='binomial', link='cloglog')
+
+        mestr = MEstimator(psi, init=[0., 0., 0.])
+        mestr.estimate(solver='lm', maxiter=5000)
+
+        fam = sm.families.Binomial(sm.families.links.cloglog())
+        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1", tol=1e-12)
+
+        # Checking mean estimate
+        npt.assert_allclose(mestr.theta,
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mestr.variance,
+                            np.asarray(glm.cov_params()),
+                            atol=1e-6)
+
+    def test_glm_binomial_loglog(self, data_b):
+        d = data_b
+
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                          distribution='binomial', link='loglog')
+
+        mestr = MEstimator(psi, init=[0., 0., 0.])
+        mestr.estimate(solver='lm', maxiter=5000)
+
+        fam = sm.families.Binomial(sm.families.links.LogLog())
+        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1", tol=1e-12)
+
+        # Checking mean estimate
+        npt.assert_allclose(mestr.theta,
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mestr.variance,
+                            np.asarray(glm.cov_params()),
+                            atol=1e-6)
+
+    def test_glm_binomial_logit_offset(self, data_b):
+        d = data_b
+
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                          distribution='binomial', link='logit',
+                          offset=d['F'])
+
+        mestr = MEstimator(psi, init=[0., 0., 0.])
+        mestr.estimate(solver='lm', maxiter=5000)
+
+        fam = sm.families.Binomial(sm.families.links.Logit())
+        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], offset=d['F'], family=fam).fit(cov_type="HC1", tol=1e-12)
+
+        # Checking mean estimate
+        npt.assert_allclose(mestr.theta,
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mestr.variance,
+                            np.asarray(glm.cov_params()),
+                            atol=1e-6)
+
+    def test_glm_gamma_log(self, data_g):
+        d = data_g
+
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X']], y=d['Y'],
+                          distribution='gamma', link='log')
+
+        mestr = MEstimator(psi, init=[0., 0., 1.])
+        mestr.estimate(solver='lm', maxiter=5000)
+
+        # Log-Gamma with statsmodels (only includes scale parameters)
+        fam = sm.families.Gamma(sm.families.links.Log())
+        glm = sm.GLM(d['Y'], d[['I', 'X']], family=fam).fit(cov_type="HC1", tol=1e-12)
+
+        # Checking mean estimate
+        npt.assert_allclose(mestr.theta[0:2],
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mestr.variance[0:2, 0:2],
+                            np.asarray(glm.cov_params()),
+                            atol=1e-6)
+
+        # Previously solved log Gamma shape parameter using MASS
+        # library(MASS)
+        # clotting <- data.frame(
+        #     u = c(5,10,15,20,30,40,60,80,100),
+        #     lot1 = c(118,58,42,35,27,25,21,19,18),
+        #     lot2 = c(69,35,26,21,18,16,13,12,12))
+        # clot1 <- glm(lot1 ~ log(u), data = clotting, family = Gamma(link='log'))
+        # summary(clot1)
+        # gamma.shape(clot1)
+        alpha_param = np.log(55.51389)
+
+        # Checking SNM parameters
+        npt.assert_allclose(mestr.theta[2],
+                            alpha_param,
+                            atol=1e-4)
+
+    def test_glm_gamma_identity(self, data_g):
+        d = data_g
+
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X']], y=d['Y'],
+                          distribution='gamma', link='identity')
+
+        mestr = MEstimator(psi, init=[100., -10., 10.])
+        mestr.estimate(solver='lm', maxiter=5000)
+
+        # Log-Gamma with statsmodels (only includes scale parameters)
+        fam = sm.families.Gamma(sm.families.links.identity())
+        glm = sm.GLM(d['Y'], d[['I', 'X']], family=fam).fit(cov_type="HC1", tol=1e-12)
+
+        # Checking mean estimate
+        npt.assert_allclose(mestr.theta[0:2],
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mestr.variance[0:2, 0:2],
+                            np.asarray(glm.cov_params()),
+                            rtol=1e-4)
+
+        # Previously solved log Gamma shape parameter using MASS
+        # library(MASS)
+        # clotting <- data.frame(
+        #     u = c(5,10,15,20,30,40,60,80,100),
+        #     lot1 = c(118,58,42,35,27,25,21,19,18),
+        #     lot2 = c(69,35,26,21,18,16,13,12,12))
+        # clot1 <- glm(lot1 ~ log(u), data = clotting, family = Gamma(link='identity'))
+        # summary(clot1)
+        # gamma.shape(clot1)
+        alpha_param = np.log(14.956340)
+
+        # Checking SNM parameters
+        npt.assert_allclose(mestr.theta[2],
+                            alpha_param,
+                            atol=1e-4)
+
+    def test_glm_gamma_inverse(self, data_g):
+        d = data_g
+
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X']], y=d['Y'],
+                          distribution='gamma', link='inverse')
+
+        mestr = MEstimator(psi, init=[0.1, 0.1, 2.])
+        mestr.estimate(solver='lm', maxiter=5000)
+
+        # Log-Gamma with statsmodels (only includes scale parameters)
+        fam = sm.families.Gamma(sm.families.links.inverse_power())
+        glm = sm.GLM(d['Y'], d[['I', 'X']], family=fam).fit(cov_type="HC1", tol=1e-12)
+
+        # Checking mean estimate
+        npt.assert_allclose(mestr.theta[0:2],
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mestr.variance[0:2, 0:2],
+                            np.asarray(glm.cov_params()),
+                            rtol=1e-5)
+
+        # Previously solved log Gamma shape parameter using MASS
+        # library(MASS)
+        # clotting <- data.frame(
+        #     u = c(5,10,15,20,30,40,60,80,100),
+        #     lot1 = c(118,58,42,35,27,25,21,19,18),
+        #     lot2 = c(69,35,26,21,18,16,13,12,12))
+        # clot1 <- glm(lot1 ~ log(u), data = clotting, family = Gamma(link='inverse'))
+        # summary(clot1)
+        # gamma.shape(clot1)
+        alpha_param = np.log(538.1315)
+
+        # Checking SNM parameters
+        npt.assert_allclose(mestr.theta[2],
+                            alpha_param,
+                            atol=1e-4)
+
+    def test_glm_gamma_log_weighted(self, data_g):
+        d = data_g
+
+        # Using the weights
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X']], y=d['Y'],
+                          distribution='gamma', link='log',
+                          weights=d['w'])
+
+        westr = MEstimator(psi, init=[0., 0., 0.])
+        westr.estimate(solver='lm', maxiter=5000)
+
+        # Using an expanded data frame instead
+        ld = pd.DataFrame(np.repeat(d.values,
+                                    d['w'],
+                                    axis=0),
+                          columns=d.columns)
+
+        def psi(theta):
+            return ee_glm(theta, X=ld[['I', 'X']], y=ld['Y'],
+                          distribution='gamma', link='log')
+
+        uestr = MEstimator(psi, init=[0., 0., 0.])
+        uestr.estimate(solver='lm', maxiter=5000)
+
+        # Checking mean estimate
+        npt.assert_allclose(westr.theta,
+                            uestr.theta,
+                            atol=1e-6)
+
+    def test_glm_invnormal_identity(self, data_cp):
+        d = data_cp
+
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                          distribution='inverse_normal', link='identity')
+
+        mestr = MEstimator(psi, init=[2., 0., 0.])
+        mestr.estimate(solver='lm', maxiter=5000)
+
+        fam = sm.families.InverseGaussian(sm.families.links.identity())
+        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1")
+
+        # Checking mean estimate
+        npt.assert_allclose(mestr.theta,
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mestr.variance,
+                            np.asarray(glm.cov_params()),
+                            atol=1e-6)
+
+    def test_glm_invnormal_log(self, data_cp):
+        d = data_cp
+
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                          distribution='inverse_normal', link='log')
+
+        mestr = MEstimator(psi, init=[0., 0., 0.])
+        mestr.estimate(solver='lm', maxiter=5000)
+
+        fam = sm.families.InverseGaussian(sm.families.links.Log())
+        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1")
+
+        # Checking mean estimate
+        npt.assert_allclose(mestr.theta,
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mestr.variance,
+                            np.asarray(glm.cov_params()),
+                            atol=1e-6)
+
+    def test_glm_tweedie_log(self, data_cp):
+        d = data_cp
+
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                          distribution='tweedie', link='log',
+                          hyperparameter=1.5)
+
+        mestr = MEstimator(psi, init=[2., 0., 0.])
+        mestr.estimate(solver='lm', maxiter=5000)
+
+        fam = sm.families.Tweedie(sm.families.links.Log(), var_power=1.5)
+        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1")
+
+        # Checking mean estimate
+        npt.assert_allclose(mestr.theta,
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mestr.variance,
+                            np.asarray(glm.cov_params()),
+                            atol=1e-6)
+
+    def test_glm_tweedie_error(self, data_cp):
+        d = data_cp
+
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                          distribution='tweedie', link='log',
+                          hyperparameter=-1)
+
+        mestr = MEstimator(psi, init=[2., 0., 0.])
+        with pytest.raises(ValueError, match="non-negative"):
+            mestr.estimate(solver='lm', maxiter=5000)
+
+    def test_glm_tweedie_lessthan1(self, data_cpp):
+        d = data_cpp
+
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                          distribution='tweedie', link='log',
+                          hyperparameter=0.5)
+
+        mestr = MEstimator(psi, init=[2., 0., 0.])
+        mestr.estimate(solver='lm', maxiter=5000)
+
+        fam = sm.families.Tweedie(sm.families.links.Log(), var_power=0.5)
+        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1")
+
+        # Checking mean estimate
+        npt.assert_allclose(mestr.theta,
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mestr.variance,
+                            np.asarray(glm.cov_params()),
+                            atol=1e-6)
+
+    def test_glm_tweedie_identity(self, data_cp):
+        d = data_cp
+
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                          distribution='tweedie', link='log',
+                          hyperparameter=1.5)
+
+        mestr = MEstimator(psi, init=[2., 0., 0.])
+        mestr.estimate(solver='lm', maxiter=5000)
+
+        fam = sm.families.Tweedie(sm.families.links.Log(), var_power=1.5)
+        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1")
+
+        # Checking mean estimate
+        npt.assert_allclose(mestr.theta,
+                            np.asarray(glm.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(mestr.variance,
+                            np.asarray(glm.cov_params()),
+                            atol=1e-6)
+
+    def test_glm_tweedie_poisson(self, data_cp):
+        d = data_cp
+
+        # Using the weights
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                          distribution='tweedie', link='log',
+                          hyperparameter=1)
+
+        testr = MEstimator(psi, init=[0., 0., 0.])
+        testr.estimate(solver='lm', maxiter=5000)
+
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                          distribution='poisson', link='log')
+
+        sestr = MEstimator(psi, init=[0., 0., 0.])
+        sestr.estimate(solver='lm', maxiter=5000)
+
+        # Checking mean estimate
+        npt.assert_allclose(testr.theta,
+                            sestr.theta,
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(testr.variance,
+                            sestr.variance,
+                            rtol=1e-6)
+
+    def test_glm_tweedie_gamma(self, data_cp):
+        d = data_cp
+
+        # Using the weights
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                          distribution='tweedie', link='log',
+                          hyperparameter=2)
+
+        testr = MEstimator(psi, init=[0., 0., 0.])
+        testr.estimate(solver='lm', maxiter=5000)
+
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                          distribution='gamma', link='log')
+
+        sestr = MEstimator(psi, init=[0., 0., 0., 0.])
+        sestr.estimate(solver='lm', maxiter=5000)
+
+        # Checking mean estimate
+        npt.assert_allclose(testr.theta,
+                            sestr.theta[0:3],
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(testr.variance,
+                            sestr.variance[0:3, 0:3],
+                            atol=1e-6)
+
+    def test_glm_tweedie_invnormal(self, data_cp):
+        d = data_cp
+
+        # Using the weights
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                          distribution='tweedie', link='log',
+                          hyperparameter=3)
+
+        testr = MEstimator(psi, init=[0., 0., 0.])
+        testr.estimate(solver='lm', maxiter=5000)
+
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                          distribution='inverse_normal', link='log')
+
+        sestr = MEstimator(psi, init=[0., 0., 0.])
+        sestr.estimate(solver='lm', maxiter=5000)
+
+        # Checking mean estimate
+        npt.assert_allclose(testr.theta,
+                            sestr.theta,
+                            atol=1e-6)
+
+        # Checking variance estimates
+        npt.assert_allclose(testr.variance,
+                            sestr.variance,
+                            rtol=1e-6)
+
+    def test_glm_nb_log(self, data_cpp):
+        d = data_cpp
+
+        # M-estimation negative binomial
+        def psi(theta):
+            ef_glm = ee_glm(theta[:-1], X=d[['I', 'X', 'Z']], y=d['Y'],
+                            distribution='nb', link='log')
+            # Transform with exponentiation to compare back to statsmodels
+            ef_ta = np.ones(d.shape[0])*(np.exp(theta[-2]) - theta[-1])
+            return np.vstack([ef_glm, ef_ta])
+
+        mestr = MEstimator(psi, init=[0., 0., 0., -2., 1.])
+        mestr.estimate(solver='lm', maxiter=5000)
+
+        # Negative Binomial using statsmodels
+        nb = sm.NegativeBinomial(d['Y'], d[['I', 'X', 'Z']],
+                                 loglike_method='nb2').fit(cov_type="HC1",
+                                                           tol=1e-10,
+                                                           method='newton')
+
+        # Checking mean estimate
+        npt.assert_allclose(list(mestr.theta[:3]) + [mestr.theta[-1], ],
+                            np.asarray(nb.params),
+                            atol=1e-6)
+
+        # Checking variance estimates
+        cov_mat = np.asarray(nb.cov_params())
+        npt.assert_allclose(mestr.variance[:3, :3],
+                            np.asarray(cov_mat[:3, :3]),
+                            atol=1e-6)
+
+        # Checking covariance for nuisance parameters (since there is an extra transform)
+        npt.assert_allclose(mestr.variance[-1, -1],
+                            np.asarray(cov_mat[-1, -1]),
+                            atol=1e-4)
+        npt.assert_allclose(mestr.variance[0, -1],
+                            np.asarray(cov_mat[0, -1]),
+                            atol=1e-4)
+        npt.assert_allclose(mestr.variance[1, -1],
+                            np.asarray(cov_mat[1, -1]),
+                            atol=1e-4)
+        npt.assert_allclose(mestr.variance[2, -1],
+                            np.asarray(cov_mat[2, -1]),
+                            atol=1e-4)
+
+    def test_glm_nb_identity(self, data_cpp):
+        d = data_cpp
+
+        # M-estimation negative binomial
+        def psi(theta):
+            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
+                          distribution='nb', link='identity')
+
+        mestr = MEstimator(psi, init=[20., 0., 0., 1.])
+        mestr.estimate(solver='lm', maxiter=5000)
+
+        # Negative Binomial using statsmodels
+        fam = sm.families.NegativeBinomial(sm.families.links.identity(), alpha=np.exp(mestr.theta[-1]))
+        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1", tol=1e-12)
+
+        # Checking mean estimate
+        npt.assert_allclose(mestr.theta[:3],
+                            np.asarray(glm.params),
+                            atol=1e-5)
+
+        # Checking variance estimates
+        # Can't check variances since statsmodels NB ignores the uncertainty in alpha
diff --git a/tests/test_ee_survival.py b/tests/test_ee_survival.py
new file mode 100644
index 0000000..2b1c16d
--- /dev/null
+++ b/tests/test_ee_survival.py
@@ -0,0 +1,520 @@
+####################################################################################################################
+# Tests for built-in estimating equations -- survival
+####################################################################################################################
+
+import pytest
+import numpy as np
+import numpy.testing as npt
+import pandas as pd
+from lifelines import ExponentialFitter, WeibullFitter, WeibullAFTFitter
+
+from delicatessen import MEstimator
+from delicatessen.estimating_equations import (ee_exponential_model, ee_exponential_measure, ee_weibull_model,
+                                               ee_weibull_measure, ee_aft_weibull, ee_aft_weibull_measure)
+
+
+class TestEstimatingEquationsSurvParam:
+
+    @pytest.fixture
+    def data_s(self):
+        times = np.array([1, 2, 3, 4, 5, 1, 1, 2, 2.5, 3, 4, 5])
+        event = np.array([1, 0, 0, 1, 1, 0, 0, 1, 0, 1, 1, 0])
+        return times, event
+
+    def test_exponential_model(self, data_s):
+        times, events = data_s
+
+        def psi(theta):
+            return ee_exponential_model(theta=theta[0],
+                                        t=times, delta=events)
+
+        mestimator = MEstimator(psi, init=[1.])
+        mestimator.estimate(solver="lm")
+
+        exf = ExponentialFitter()
+        exf.fit(times, events)
+        results = np.asarray(exf.summary[['coef', 'se(coef)', 'coef lower 95%', 'coef upper 95%']])
+
+        # Checking mean estimate
+        npt.assert_allclose(1 / mestimator.theta[0],
+                            np.asarray(results[0, 0]),
+                            atol=1e-5)
+        # No robust variance for lifeline's ExponentialFitter, so not checking against
+        # Checking variance estimates
+        # npt.assert_allclose(np.sqrt(np.diag(mestimator.variance)),
+        #                     np.asarray(results[0, 1]),
+        #                     atol=1e-6)
+        # Checking confidence interval estimates
+        # npt.assert_allclose(mestimator.confidence_intervals(),
+        #                     np.asarray(results[0, 2:]),
+        #                     atol=1e-5)
+
+    def test_exponential_survival(self, data_s):
+        times, events = data_s
+
+        def psi(theta):
+            ee_exp = ee_exponential_model(theta=theta[0],
+                                          t=times, delta=events)
+            ee_surv = ee_exponential_measure(theta[1:], scale=theta[0],
+                                             times=[0.5, 1, 2, 3], n=times.shape[0],
+                                             measure="survival")
+            return np.vstack((ee_exp, ee_surv))
+
+        mestimator = MEstimator(psi, init=[1., 0.5, 0.5, 0.5, 0.5])
+        mestimator.estimate(solver="lm")
+
+        exf = ExponentialFitter()
+        exf.fit(times, events)
+        results = np.asarray(exf.survival_function_at_times(times=[0.5, 1, 2, 3]))
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta[1:],
+                            results,
+                            atol=1e-5)
+
+    def test_exponential_risk(self, data_s):
+        times, events = data_s
+
+        def psi(theta):
+            ee_exp = ee_exponential_model(theta=theta[0],
+                                          t=times, delta=events)
+            ee_surv = ee_exponential_measure(theta[1:], scale=theta[0],
+                                             times=[0.5, 1, 2, 3], n=times.shape[0],
+                                             measure="risk")
+            return np.vstack((ee_exp, ee_surv))
+
+        mestimator = MEstimator(psi, init=[1., 0.5, 0.5, 0.5, 0.5])
+        mestimator.estimate(solver="lm")
+
+        exf = ExponentialFitter()
+        exf.fit(times, events)
+        results = exf.cumulative_density_at_times(times=[0.5, 1, 2, 3])
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta[1:],
+                            results,
+                            atol=1e-5)
+
+    def test_exponential_hazard(self, data_s):
+        times, events = data_s
+
+        def psi(theta):
+            ee_exp = ee_exponential_model(theta=theta[0],
+                                          t=times, delta=events)
+            ee_surv = ee_exponential_measure(theta[1:], scale=theta[0],
+                                             times=[0.5, 1, 2, 3], n=times.shape[0],
+                                             measure="hazard")
+            return np.vstack((ee_exp, ee_surv))
+
+        mestimator = MEstimator(psi, init=[1., 0.5, 0.5, 0.5, 0.5])
+        mestimator.estimate(solver="lm")
+
+        exf = ExponentialFitter()
+        exf.fit(times, events)
+        results = np.asarray(exf.summary['coef'])[0]
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta[1:],
+                            [1/results]*4,
+                            atol=1e-5)
+
+    def test_exponential_cumulative_hazard(self, data_s):
+        times, events = data_s
+
+        def psi(theta):
+            ee_exp = ee_exponential_model(theta=theta[0],
+                                          t=times, delta=events)
+            ee_surv = ee_exponential_measure(theta[1:], scale=theta[0],
+                                             times=[0.5, 1, 2, 3], n=times.shape[0],
+                                             measure="cumulative_hazard")
+            return np.vstack((ee_exp, ee_surv))
+
+        mestimator = MEstimator(psi, init=[0.2, 0., 0., 0., 0.])
+        mestimator.estimate(solver="lm")
+
+        exf = ExponentialFitter()
+        exf.fit(times, events)
+        results = exf.cumulative_hazard_at_times(times=[0.5, 1, 2, 3])
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta[1:],
+                            results,
+                            atol=1e-5)
+
+    def test_exponential_density(self, data_s):
+        times, events = data_s
+
+        def psi(theta):
+            ee_exp = ee_exponential_model(theta=theta[0],
+                                          t=times, delta=events)
+            ee_surv = ee_exponential_measure(theta[1:], scale=theta[0],
+                                             times=[0.5, 1, 2, 3], n=times.shape[0],
+                                             measure="density")
+            return np.vstack((ee_exp, ee_surv))
+
+        mestimator = MEstimator(psi, init=[0.2, 0., 0., 0., 0.])
+        mestimator.estimate(solver="lm")
+
+        # NOTICE: lifelines fails here (some problem with the derivative), so skipping comparison
+        #   the density measure is still covered by the Weibull density prediction (so not a testing coverage problem)
+        # exf = ExponentialFitter()
+        # exf.fit(times, events)
+        # results = exf.density_at_times(times=[0.5, 1, 2, 3])
+        #
+        # # Checking mean estimate
+        # npt.assert_allclose(mestimator.theta[1:],
+        #                     results,
+        #                     atol=1e-5)
+        pass
+
+    def test_weibull_model(self, data_s):
+        times, events = data_s
+
+        def psi(theta):
+            return ee_weibull_model(theta=theta,
+                                    t=times, delta=events)
+
+        mestimator = MEstimator(psi, init=[1., 1.])
+        mestimator.estimate(solver="lm")
+
+        wbf = WeibullFitter()
+        wbf.fit(times, events)
+        results = np.asarray(wbf.summary[['coef', 'se(coef)', 'coef lower 95%', 'coef upper 95%']])
+
+        # Checking mean estimate
+        npt.assert_allclose([(1 / mestimator.theta[0])**(1/mestimator.theta[1]), mestimator.theta[1]],
+                            np.asarray(results[:, 0]),
+                            atol=1e-4)
+
+        # No robust variance for lifeline's WeibullFitter, so not checking against
+        # Checking variance estimates
+        # npt.assert_allclose(np.sqrt(np.diag(mestimator.variance)),
+        #                     np.asarray(results[0, 1]),
+        #                     atol=1e-6)
+
+        # Checking confidence interval estimates
+        # npt.assert_allclose(mestimator.confidence_intervals(),
+        #                     np.asarray(results[0, 2:]),
+        #                     atol=1e-5)
+
+    def test_weibull_survival(self, data_s):
+        times, events = data_s
+
+        def psi(theta):
+            ee_wbl = ee_weibull_model(theta=theta[0:2],
+                                      t=times, delta=events)
+            ee_surv = ee_weibull_measure(theta[2:], scale=theta[0], shape=theta[1],
+                                         times=[0.5, 1, 2, 3], n=times.shape[0],
+                                         measure="survival")
+            return np.vstack((ee_wbl, ee_surv))
+
+        mestimator = MEstimator(psi, init=[1., 1., 0.5, 0.5, 0.5, 0.5])
+        mestimator.estimate(solver="lm")
+
+        wbf = WeibullFitter()
+        wbf.fit(times, events)
+        results = np.asarray(wbf.survival_function_at_times(times=[0.5, 1, 2, 3]))
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta[2:],
+                            results,
+                            atol=1e-5)
+
+    def test_weibull_risk(self, data_s):
+        times, events = data_s
+
+        def psi(theta):
+            ee_wbl = ee_weibull_model(theta=theta[0:2],
+                                      t=times, delta=events)
+            ee_surv = ee_weibull_measure(theta[2:], scale=theta[0], shape=theta[1],
+                                         times=[0.5, 1, 2, 3], n=times.shape[0],
+                                         measure="risk")
+            return np.vstack((ee_wbl, ee_surv))
+
+        mestimator = MEstimator(psi, init=[1., 1., 0.5, 0.5, 0.5, 0.5])
+        mestimator.estimate(solver="lm")
+
+        wbf = WeibullFitter()
+        wbf.fit(times, events)
+        results = np.asarray(wbf.cumulative_density_at_times(times=[0.5, 1, 2, 3]))
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta[2:],
+                            results,
+                            atol=1e-5)
+
+    def test_weibull_hazard(self, data_s):
+        times, events = data_s
+
+        def psi(theta):
+            ee_wbl = ee_weibull_model(theta=theta[0:2],
+                                      t=times, delta=events)
+            ee_surv = ee_weibull_measure(theta[2:], scale=theta[0], shape=theta[1],
+                                         times=[0.5, 1, 2, 3], n=times.shape[0],
+                                         measure="hazard")
+            return np.vstack((ee_wbl, ee_surv))
+
+        mestimator = MEstimator(psi, init=[1., 1., 0.5, 0.5, 0.5, 0.5])
+        mestimator.estimate(solver="lm")
+
+        wbf = WeibullFitter()
+        wbf.fit(times, events)
+        results = np.asarray(wbf.hazard_at_times(times=[0.5, 1, 2, 3]))
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta[2:],
+                            results,
+                            atol=1e-4)
+
+    def test_weibull_cumulative_hazard(self, data_s):
+        times, events = data_s
+
+        def psi(theta):
+            ee_wbl = ee_weibull_model(theta=theta[0:2],
+                                      t=times, delta=events)
+            ee_surv = ee_weibull_measure(theta[2:], scale=theta[0], shape=theta[1],
+                                         times=[0.5, 1, 2, 3], n=times.shape[0],
+                                         measure="cumulative_hazard")
+            return np.vstack((ee_wbl, ee_surv))
+
+        mestimator = MEstimator(psi, init=[1., 1., 0.5, 0.5, 0.5, 0.5])
+        mestimator.estimate(solver="lm")
+
+        wbf = WeibullFitter()
+        wbf.fit(times, events)
+        results = np.asarray(wbf.cumulative_hazard_at_times(times=[0.5, 1, 2, 3]))
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta[2:],
+                            results,
+                            atol=1e-4)
+
+    def test_weibull_density(self, data_s):
+        times, events = data_s
+
+        def psi(theta):
+            ee_wbl = ee_weibull_model(theta=theta[0:2],
+                                      t=times, delta=events)
+            ee_surv = ee_weibull_measure(theta[2:], scale=theta[0], shape=theta[1],
+                                         times=[0.5, 1, 2, 3], n=times.shape[0],
+                                         measure="density")
+            return np.vstack((ee_wbl, ee_surv))
+
+        mestimator = MEstimator(psi, init=[1., 1., 0.5, 0.5, 0.5, 0.5])
+        mestimator.estimate(solver="lm")
+
+        wbf = WeibullFitter()
+        wbf.fit(times, events)
+        results = np.asarray(wbf.density_at_times(times=[0.5, 1, 2, 3]))
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta[2:],
+                            results,
+                            atol=1e-5)
+
+
+class TestEstimatingEquationsAFT:
+
+    @pytest.fixture
+    def data_sc(self):
+        d = pd.DataFrame()
+        d['X'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
+        d['W'] = [1, 2, 3, 1, -1, 1, -2, 1, 0, 1, -3, 1, 0, 1, -1, 3, 0, 2, 1, 1, -1, 2, -3, 2, -2, 0, 0, 1, 1, 2, 3, 0, 1, 0, -1]
+        d['t'] = [1, 2, 3, 5, 1, 9, 10, 1, 3, 6, 8, 6, 9, 4, 3, 2, 1, 1, 5, 6, 7, 8, 3, 4, 4, 3, 2.1, 3, 5, 3, 2, 1, 3, 2, 10]
+        d['delta'] = [1, 0, 0, 1, 0, 1, 1, 0, 1, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 1, 0]
+        d['weight'] = [1, 2, 1, 0.1, 1, 1, 1, 3, 1, 1, 3, 1, 2, 1, 1, 2, 1, 2, 1, 1, 2, 1, 1, 1, 1, 1, 6, 1, 1, 1, 1, 2, 1, 4, 1]
+        return d
+
+    def test_weibull_aft(self, data_sc):
+        def psi(theta):
+            return ee_aft_weibull(theta=theta,
+                                  t=data_sc['t'], delta=data_sc['delta'], X=data_sc[['X', 'W']])
+
+        # # M-estimator with built-in Weibull AFT
+        mestimator = MEstimator(psi, init=[1., 0., 0., 0.6])
+        mestimator.estimate(solver="lm")
+
+        # Weibull AFT from lifelines for comparison
+        waft = WeibullAFTFitter()
+        waft.fit(data_sc[['X', 'W', 't', 'delta']], 't', 'delta',
+                 ancillary=False, robust=True)
+        results = np.asarray(waft.summary[['coef', 'se(coef)', 'coef lower 95%', 'coef upper 95%']])
+        results = results[[2, 1, 0, 3], :]
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta,
+                            np.asarray(results[:, 0]),
+                            atol=1e-5)
+
+        # Checking variance estimates
+        npt.assert_allclose(np.sqrt(np.diag(mestimator.variance)),
+                            np.asarray(results[:, 1]),
+                            atol=1e-6)
+
+        # Checking confidence interval estimates
+        npt.assert_allclose(mestimator.confidence_intervals(),
+                            np.asarray(results[:, 2:]),
+                            atol=1e-5)
+
+    def test_weighted_weibull_aft(self, data_sc):
+        def psi(theta):
+            return ee_aft_weibull(theta=theta, weights=data_sc['weight'],
+                                  t=data_sc['t'], delta=data_sc['delta'], X=data_sc[['X', 'W']])
+
+        # M-estimator with built-in Weibull AFT
+        mestimator = MEstimator(psi, init=[1., 0., 0., 0.6])
+        mestimator.estimate(solver="lm")
+
+        # Weibull AFT from lifelines for comparison
+        waft = WeibullAFTFitter()
+        waft.fit(data_sc[['X', 'W', 't', 'delta', 'weight']], 't', 'delta',
+                 weights_col='weight', ancillary=False, robust=True)
+        results = np.asarray(waft.summary[['coef', 'se(coef)', 'coef lower 95%', 'coef upper 95%']])
+        results = results[[2, 1, 0, 3], :]
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta,
+                            np.asarray(results[:, 0]),
+                            atol=1e-5)
+
+        # No variance check, since lifelines uses a different estimator
+
+    def test_weibull_aft_survival(self, data_sc):
+        # Times to evaluate and covariate pattern to examine
+        times_to_eval = [1, 1.25, 3, 5]
+        dta = data_sc.copy()
+        dta['X'] = 1
+        dta['W'] = 1
+
+        def psi(theta):
+            aft = ee_aft_weibull(theta=theta[0:4], t=data_sc['t'], delta=data_sc['delta'], X=data_sc[['X', 'W']])
+            pred_surv_t = ee_aft_weibull_measure(theta=theta[4:], X=dta[['X', 'W']],
+                                                 times=times_to_eval, measure='survival',
+                                                 mu=theta[0], beta=theta[1:3], sigma=theta[3])
+            return np.vstack((aft, pred_surv_t))
+
+        # M-estimator with built-in Weibull AFT
+        mestimator = MEstimator(psi, init=[1., 0., 0., 0.6] + [0.5, ]*len(times_to_eval))
+        mestimator.estimate(solver="lm")
+
+        # Predictions from Weibull AFT from lifelines for comparison
+        waft = WeibullAFTFitter()
+        waft.fit(data_sc[['X', 'W', 't', 'delta']], 't', 'delta', ancillary=False, robust=True)
+        preds = waft.predict_survival_function(dta.iloc[0], times=times_to_eval)
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta[4:],
+                            np.asarray(preds).T[0],
+                            atol=1e-5)
+
+    def test_weibull_aft_risk(self, data_sc):
+        times_to_eval = [1, 1.25, 3, 5]
+        dta = data_sc.copy()
+        dta['X'] = 1
+        dta['W'] = 1
+
+        def psi(theta):
+            aft = ee_aft_weibull(theta=theta[0:4], t=data_sc['t'], delta=data_sc['delta'], X=data_sc[['X', 'W']])
+            pred_surv_t = ee_aft_weibull_measure(theta=theta[4:], X=dta[['X', 'W']],
+                                                 times=times_to_eval, measure='risk',
+                                                 mu=theta[0], beta=theta[1:3], sigma=theta[3])
+            return np.vstack((aft, pred_surv_t))
+
+        # M-estimator with built-in Weibull AFT
+        mestimator = MEstimator(psi, init=[1., 0., 0., 0.6] + [0.5, ]*len(times_to_eval))
+        mestimator.estimate(solver="lm")
+
+        # Predictions from Weibull AFT from lifelines for comparison
+        waft = WeibullAFTFitter()
+        waft.fit(data_sc[['X', 'W', 't', 'delta']], 't', 'delta', ancillary=False, robust=True)
+        preds = 1 - waft.predict_survival_function(dta.iloc[0], times=times_to_eval)
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta[4:],
+                            np.asarray(preds).T[0],
+                            atol=1e-5)
+
+    def test_weibull_aft_density(self, data_sc):
+        # Times to evaluate and covariate pattern to examine
+        times_to_eval = [1, 1.25, 3, 5]
+        dta = data_sc.copy()
+        dta['X'] = 1
+        dta['W'] = 1
+
+        def psi(theta):
+            aft = ee_aft_weibull(theta=theta[0:4], t=data_sc['t'], delta=data_sc['delta'], X=data_sc[['X', 'W']])
+            pred_surv_t = ee_aft_weibull_measure(theta=theta[4:], X=dta[['X', 'W']],
+                                                 times=times_to_eval, measure='density',
+                                                 mu=theta[0], beta=theta[1:3], sigma=theta[3])
+            return np.vstack((aft, pred_surv_t))
+
+        # M-estimator with built-in Weibull AFT
+        mestimator = MEstimator(psi, init=[1., 0., 0., 0.6] + [0.5, ]*len(times_to_eval))
+        mestimator.estimate(solver="lm")
+
+        # Predictions from Weibull AFT from lifelines for comparison
+        waft = WeibullAFTFitter()
+        waft.fit(data_sc[['X', 'W', 't', 'delta']], 't', 'delta', ancillary=False, robust=True)
+        preds = (waft.predict_survival_function(dta.iloc[0], times=times_to_eval)
+                 * waft.predict_hazard(dta.iloc[0], times=times_to_eval))
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta[4:],
+                            np.asarray(preds).T[0],
+                            atol=1e-5)
+
+    def test_weibull_aft_hazard(self, data_sc):
+        # Times to evaluate and covariate pattern to examine
+        times_to_eval = [1, 1.25, 3, 5]
+        dta = data_sc.copy()
+        dta['X'] = 1
+        dta['W'] = 1
+
+        def psi(theta):
+            aft = ee_aft_weibull(theta=theta[0:4], t=data_sc['t'], delta=data_sc['delta'], X=data_sc[['X', 'W']])
+            pred_surv_t = ee_aft_weibull_measure(theta=theta[4:], X=dta[['X', 'W']],
+                                                 times=times_to_eval, measure='hazard',
+                                                 mu=theta[0], beta=theta[1:3], sigma=theta[3])
+            return np.vstack((aft, pred_surv_t))
+
+        # M-estimator with built-in Weibull AFT
+        mestimator = MEstimator(psi, init=[1., 0., 0., 0.6] + [0.5, ]*len(times_to_eval))
+        mestimator.estimate(solver="lm")
+
+        # Predictions from Weibull AFT from lifelines for comparison
+        waft = WeibullAFTFitter()
+        waft.fit(data_sc[['X', 'W', 't', 'delta']], 't', 'delta', ancillary=False, robust=True)
+        preds = waft.predict_hazard(dta.iloc[0], times=times_to_eval)
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta[4:],
+                            np.asarray(preds).T[0],
+                            atol=1e-5)
+
+    def test_weibull_aft_cumulative_hazard(self, data_sc):
+        # Times to evaluate and covariate pattern to examine
+        times_to_eval = [1, 1.25, 3, 5]
+        dta = data_sc.copy()
+        dta['X'] = 1
+        dta['W'] = 1
+
+        def psi(theta):
+            aft = ee_aft_weibull(theta=theta[0:4], t=data_sc['t'], delta=data_sc['delta'], X=data_sc[['X', 'W']])
+            pred_surv_t = ee_aft_weibull_measure(theta=theta[4:], X=dta[['X', 'W']],
+                                                 times=times_to_eval, measure='cumulative_hazard',
+                                                 mu=theta[0], beta=theta[1:3], sigma=theta[3])
+            return np.vstack((aft, pred_surv_t))
+
+        # M-estimator with built-in Weibull AFT
+        mestimator = MEstimator(psi, init=[1., 0., 0., 0.6] + [0.5, ]*len(times_to_eval))
+        mestimator.estimate(solver="lm")
+
+        # Predictions from Weibull AFT from lifelines for comparison
+        waft = WeibullAFTFitter()
+        waft.fit(data_sc[['X', 'W', 't', 'delta']], 't', 'delta', ancillary=False, robust=True)
+        preds = waft.predict_cumulative_hazard(dta.iloc[0], times=times_to_eval)
+
+        # Checking mean estimate
+        npt.assert_allclose(mestimator.theta[4:],
+                            np.asarray(preds).T[0],
+                            atol=1e-4)
diff --git a/tests/test_estimating_equations.py b/tests/test_estimating_equations.py
deleted file mode 100644
index 08101c7..0000000
--- a/tests/test_estimating_equations.py
+++ /dev/null
@@ -1,3273 +0,0 @@
-import pytest
-import warnings
-import numpy as np
-import numpy.testing as npt
-import pandas as pd
-import statsmodels.api as sm
-import statsmodels.formula.api as smf
-from statsmodels.tools.sm_exceptions import DomainWarning
-from scipy.stats import logistic
-from lifelines import ExponentialFitter, WeibullFitter, WeibullAFTFitter
-
-from delicatessen import MEstimator
-from delicatessen.estimating_equations import (ee_mean, ee_mean_variance, ee_mean_robust,
-                                               # Regression models
-                                               ee_regression, ee_glm, ee_robust_regression, ee_ridge_regression,
-                                               ee_additive_regression, ee_elasticnet_regression,
-                                               # Survival models
-                                               ee_exponential_model, ee_exponential_measure, ee_weibull_model,
-                                               ee_weibull_measure, ee_aft_weibull, ee_aft_weibull_measure,
-                                               # Dose-Response
-                                               ee_2p_logistic, ee_3p_logistic, ee_4p_logistic, ee_effective_dose_delta,
-                                               # Causal inference
-                                               ee_gformula, ee_ipw, ee_ipw_msm, ee_aipw, ee_gestimation_snmm,
-                                               ee_mean_sensitivity_analysis)
-from delicatessen.data import load_inderjit
-from delicatessen.utilities import additive_design_matrix, inverse_logit
-
-
-np.random.seed(236461)
-
-# TODO write more compact data generation (as functions instead of unique creations in g-estimation)
-
-
-class TestEstimatingEquationsBase:
-
-    def test_mean(self):
-        """Tests mean with the built-in estimating equation.
-        """
-        # Data set
-        y = np.array([5, 1, 2, 4, 2, 4, 5, 7, 11, 1, 6, 3, 4, 6])
-
-        def psi1(theta):
-            return y - theta
-
-        mcee = MEstimator(psi1, init=[0, ])
-        mcee.estimate()
-
-        def psi2(theta):
-            return ee_mean(theta, y=y)
-
-        mpee = MEstimator(psi2, init=[0, ])
-        mpee.estimate()
-
-        # Checking mean estimate
-        npt.assert_allclose(mcee.theta,
-                            mpee.theta,
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mcee.asymptotic_variance,
-                            mpee.asymptotic_variance,
-                            atol=1e-6)
-
-    def test_mean_robust(self):
-        y = [-10, -1, 2, 3, -2, 0, 3, 5, 12]
-
-        def byhand(theta):
-            k = 6
-            ee_rm = np.array(y) - theta
-            ee_rm = np.where(ee_rm > k, k, ee_rm)
-            ee_rm = np.where(ee_rm < -k, -k, ee_rm)
-            return ee_rm
-
-        ref = MEstimator(byhand, init=[0, ])
-        ref.estimate()
-
-        def psi(theta):
-            return ee_mean_robust(theta=theta, y=y, k=6)
-
-        mestimator = MEstimator(psi, init=[0, ])
-        mestimator.estimate()
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta[0],
-                            ref.theta[0],
-                            atol=1e-9)
-
-    def test_mean_variance(self):
-        """Tests mean-variance with the built-in estimating equations.
-        """
-        # Data set
-        y = np.array([5, 1, 2, 4, 2, 4, 5, 7, 11, 1, 6, 3, 4, 6])
-
-        def psi1(theta):
-            return y - theta[0], (y - theta[0]) ** 2 - theta[1]
-
-        mcee = MEstimator(psi1, init=[0, 0, ])
-        mcee.estimate()
-
-        def psi2(theta):
-            return ee_mean_variance(theta=theta, y=y)
-
-        mpee = MEstimator(psi2, init=[0, 0, ])
-        mpee.estimate()
-
-        # Checking mean estimate
-        npt.assert_allclose(mcee.theta,
-                            mpee.theta,
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mcee.asymptotic_variance,
-                            mpee.asymptotic_variance,
-                            atol=1e-6)
-
-
-class TestEstimatingEquationsRegression:
-
-    def test_error_regression(self):
-        """Test for error raised when incorrect regression name is provided
-        """
-        n = 100
-        data = pd.DataFrame()
-        data['x1'] = np.random.normal(size=n)
-        data['x2'] = data['x1'] + np.random.normal(scale=0.1, size=n)
-        data['c'] = 1
-        data['y'] = 5 + data['x1'] + np.random.normal(size=n)
-        Xvals = np.asarray(data[['c', 'x1', 'x2']])
-        yvals = np.asarray(data['y'])
-
-        def psi(theta):
-            return ee_regression(theta, X=Xvals, y=yvals, model=748)
-
-        estr = MEstimator(psi, init=[5, 1, 1])
-        with pytest.raises(ValueError, match="The model argument"):
-            estr.estimate(solver='lm')
-
-        def psi(theta):
-            return ee_regression(theta, X=Xvals, y=yvals, model='magic')
-
-        estr = MEstimator(psi, init=[5, 1, 1])
-        with pytest.raises(ValueError, match="Invalid input"):
-            estr.estimate(solver='lm')
-
-    def test_error_robust(self):
-        n = 1000
-        data = pd.DataFrame()
-        data['x1'] = np.random.normal(size=n)
-        data['x2'] = data['x1'] + np.random.normal(scale=0.1, size=n)
-        data['c'] = 1
-        data['y'] = 5 + data['x1'] + np.random.normal(size=n)
-        Xvals = np.asarray(data[['c', 'x1', 'x2']])
-        yvals = np.asarray(data['y'])
-
-        def psi(theta):
-            return ee_robust_regression(theta, X=Xvals, y=yvals, model='logistic', k=5)
-
-        estr = MEstimator(psi, init=[5, 1, 1])
-        with pytest.raises(ValueError, match="only supports linear"):
-            estr.estimate(solver='lm')
-
-    def test_error_penalized(self):
-        n = 1000
-        data = pd.DataFrame()
-        data['x1'] = np.random.normal(size=n)
-        data['x2'] = data['x1'] + np.random.normal(scale=0.1, size=n)
-        data['c'] = 1
-        data['y'] = 5 + data['x1'] + np.random.normal(size=n)
-        Xvals = np.asarray(data[['c', 'x1', 'x2']])
-        yvals = np.asarray(data['y'])
-
-        def psi(theta):
-            return ee_ridge_regression(theta, X=Xvals, y=yvals, model='linear',
-                                       penalty=[0.5, 5.], weights=None)
-
-        estr = MEstimator(psi, init=[5, 1, 1])
-        with pytest.raises(ValueError, match="The penalty term must"):
-            estr.estimate(solver='lm')
-
-    def test_ols(self):
-        """Tests linear regression with the built-in estimating equation.
-        """
-        n = 500
-        data = pd.DataFrame()
-        data['X'] = np.random.normal(size=n)
-        data['Z'] = np.random.normal(size=n)
-        data['Y'] = 0.5 + 2*data['X'] - 1*data['Z'] + np.random.normal(loc=0, size=n)
-        data['C'] = 1
-
-        def psi_builtin_regression(theta):
-            return ee_regression(theta,
-                                 X=data[['C', 'X', 'Z']], y=data['Y'],
-                                 model='linear')
-
-        mpee = MEstimator(psi_builtin_regression, init=[0.1, 0.1, 0.1])
-        mpee.estimate()
-
-        # Statsmodels function equivalent
-        glm = smf.glm("Y ~ X + Z", data).fit(cov_type="HC1")
-
-        # Checking mean estimate
-        npt.assert_allclose(mpee.theta,
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mpee.variance,
-                            np.asarray(glm.cov_params()),
-                            atol=1e-6)
-
-        # Checking confidence interval estimates
-        npt.assert_allclose(mpee.confidence_intervals(),
-                            np.asarray(glm.conf_int()),
-                            atol=1e-6)
-
-    def test_ols_offset(self):
-        """Tests linear regression with the built-in estimating equation and an offset term.
-        """
-        n = 500
-        data = pd.DataFrame()
-        data['X'] = np.random.normal(size=n)
-        data['Z'] = np.random.normal(size=n)
-        data['Y'] = 0.5 + 2*data['X'] - 1*data['Z'] + np.random.normal(loc=0, size=n)
-        data['C'] = 1
-
-        def psi_builtin_regression(theta):
-            return ee_regression(theta,
-                                 X=data[['C', 'X', 'Z']], y=data['Y'],
-                                 offset=data['C'],
-                                 model='linear')
-
-        mpee = MEstimator(psi_builtin_regression, init=[0.1, 0.1, 0.1])
-        mpee.estimate()
-
-        # Statsmodels function equivalent
-        glm = smf.glm("Y ~ X + Z", data, offset=data['C']).fit(cov_type="HC1")
-
-        # Checking mean estimate
-        npt.assert_allclose(mpee.theta,
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mpee.variance,
-                            np.asarray(glm.cov_params()),
-                            atol=1e-6)
-
-        # Checking confidence interval estimates
-        npt.assert_allclose(mpee.confidence_intervals(),
-                            np.asarray(glm.conf_int()),
-                            atol=1e-6)
-
-    def test_wls(self):
-        """Tests weighted linear regression
-        """
-        n = 500
-        data = pd.DataFrame()
-        data['X'] = np.random.normal(size=n)
-        data['Z'] = np.random.normal(size=n)
-        data['Y'] = 0.5 + 2 * data['X'] - 1 * data['Z'] + np.random.normal(loc=0, size=n)
-        data['C'] = 1
-        data['w'] = np.random.uniform(1, 10, size=n)
-
-        def psi_regression(theta):
-            return ee_regression(theta,
-                                 X=data[['C', 'X', 'Z']], y=data['Y'],
-                                 model='linear', weights=data['w'])
-
-        mestimator = MEstimator(psi_regression, init=[0.1, 0.1, 0.1])
-        mestimator.estimate()
-
-        # Comparing to statsmodels GLM (with robust covariance)
-        glm = smf.glm("Y ~ X + Z", data, freq_weights=data['w']).fit(cov_type="cluster",
-                                                                     cov_kwds={"groups": data.index,
-                                                                               "use_correction": False})
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta,
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mestimator.variance,
-                            np.asarray(glm.cov_params()),
-                            atol=1e-6)
-
-        # Checking confidence interval estimates
-        npt.assert_allclose(mestimator.confidence_intervals(),
-                            np.asarray(glm.conf_int()),
-                            atol=1e-6)
-
-    def test_wls_offset(self):
-        """Tests weighted linear regression with an offset term
-        """
-        n = 500
-        data = pd.DataFrame()
-        data['X'] = np.random.normal(size=n)
-        data['Z'] = np.random.normal(size=n)
-        data['Y'] = 0.5 + 2 * data['X'] - 1 * data['Z'] + np.random.normal(loc=0, size=n)
-        data['C'] = 1
-        data['w'] = np.random.uniform(1, 10, size=n)
-
-        def psi_regression(theta):
-            return ee_regression(theta,
-                                 X=data[['C', 'X', 'Z']], y=data['Y'],
-                                 model='linear', weights=data['w'], offset=data['C'])
-
-        mestimator = MEstimator(psi_regression, init=[0.1, 0.1, 0.1])
-        mestimator.estimate()
-
-        # Comparing to statsmodels GLM (with robust covariance)
-        glm = smf.glm("Y ~ X + Z", data, freq_weights=data['w'],
-                      offset=data['C']).fit(cov_type="cluster",
-                                            cov_kwds={"groups": data.index,
-                                                      "use_correction": False})
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta,
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mestimator.variance,
-                            np.asarray(glm.cov_params()),
-                            atol=1e-6)
-
-        # Checking confidence interval estimates
-        npt.assert_allclose(mestimator.confidence_intervals(),
-                            np.asarray(glm.conf_int()),
-                            atol=1e-6)
-
-    def test_ridge_ols(self):
-        """Tests the ridge (L2) variation of the linear regression built-in estimating equation
-        """
-        n = 1000
-        data = pd.DataFrame()
-        data['x1'] = np.random.normal(size=n)
-        data['x2'] = data['x1'] + np.random.normal(scale=0.1, size=n)
-        data['c'] = 1
-        data['y'] = 5 + data['x1'] + np.random.normal(size=n)
-        Xvals = np.asarray(data[['c', 'x1', 'x2']])
-        yvals = np.asarray(data['y'])
-
-        # Penalty of 0.5
-        def psi(theta):
-            return ee_ridge_regression(theta, X=Xvals, y=yvals, model='linear', penalty=0.5, weights=None)
-
-        estr = MEstimator(psi, init=[5, 1, 1])
-        estr.estimate(solver='lm')
-        ridge = sm.OLS(yvals, Xvals).fit_regularized(L1_wt=0., alpha=0.5 / Xvals.shape[0])
-
-        # Checking mean estimate
-        npt.assert_allclose(estr.theta,
-                            np.asarray(ridge.params),
-                            atol=1e-6)
-
-        # Penalty of 5.0
-        def psi(theta):
-            return ee_ridge_regression(theta, X=Xvals, y=yvals, model='linear', penalty=5.0, weights=None)
-
-        estr = MEstimator(psi, init=[5, 1, 1])
-        estr.estimate(solver='lm')
-        ridge = sm.OLS(yvals, Xvals).fit_regularized(L1_wt=0., alpha=5. / Xvals.shape[0])
-
-        # Checking mean estimate
-        npt.assert_allclose(estr.theta,
-                            np.asarray(ridge.params),
-                            atol=1e-6)
-
-        # Testing array of penalty terms
-        def psi(theta):
-            return ee_ridge_regression(theta, X=Xvals, y=yvals, model='linear', penalty=[0., 5., 2.], weights=None)
-
-        estr = MEstimator(psi, init=[5, 1, 1])
-        estr.estimate(solver='lm')
-        ridge = sm.OLS(yvals, Xvals).fit_regularized(L1_wt=0., alpha=np.array([0., 5., 2.]) / Xvals.shape[0])
-
-        # Checking mean estimate
-        npt.assert_allclose(estr.theta,
-                            np.asarray(ridge.params),
-                            atol=1e-6)
-
-    def test_ridge_ols_offset(self):
-        """Tests the ridge (L2) variation of the linear regression built-in estimating equation with an offset term
-        """
-        n = 10000
-        data = pd.DataFrame()
-        data['x1'] = np.random.normal(size=n)
-        data['x2'] = data['x1'] + np.random.normal(scale=0.1, size=n)
-        data['c'] = 1
-        data['y'] = 5 + data['x1'] + np.random.normal(size=n)
-        data['off'] = np.random.uniform(0.4, 0.6, size=n)
-        # data['off'] = 1
-        Xvals = np.asarray(data[['c', 'x1', 'x2']])
-        yvals = np.asarray(data['y'])
-
-        # Penalty of 0.5
-        def psi(theta):
-            return ee_ridge_regression(theta, X=Xvals, y=yvals, model='linear',
-                                       penalty=[0., 5., 2.],
-                                       weights=None, offset=data['off']
-                                       )
-
-        estr = MEstimator(psi, init=[5, 1, 1])
-        estr.estimate(solver='lm')
-        ridge = sm.OLS(yvals - data['off'], Xvals,).fit_regularized(L1_wt=0.,
-                                                                    alpha=np.array([0., 5., 2.]) / Xvals.shape[0])
-
-        # Checking mean estimate
-        npt.assert_allclose(estr.theta,
-                            np.asarray(ridge.params),
-                            atol=1e-6)
-
-    def test_ridge_wls(self):
-        """Tests the ridge (L2) variation of the weighted linear regression built-in estimating equation
-        """
-        n = 1000
-        data = pd.DataFrame()
-        data['x1'] = np.random.normal(size=n)
-        data['x2'] = data['x1'] + np.random.normal(scale=0.1, size=n)
-        data['c'] = 1
-        data['y'] = 5 + data['x1'] + np.random.normal(size=n)
-        Xvals = np.asarray(data[['c', 'x1', 'x2']])
-        yvals = np.asarray(data['y'])
-        weights = np.random.uniform(0.1, 2.5, size=n)
-
-        # Penalty of 0.5
-        def psi(theta):
-            return ee_ridge_regression(theta, X=Xvals, y=yvals, model='linear', penalty=0.5, weights=weights)
-
-        estr = MEstimator(psi, init=[5, 1, 1])
-        estr.estimate(solver='lm')
-        wridge = sm.WLS(yvals, Xvals, weights=weights).fit_regularized(L1_wt=0., alpha=0.5 / Xvals.shape[0])
-
-        # Checking mean estimate
-        npt.assert_allclose(estr.theta,
-                            np.asarray(wridge.params),
-                            atol=1e-6)
-
-        # Penalty of 5.0
-        def psi(theta):
-            return ee_ridge_regression(theta, X=Xvals, y=yvals, model='linear', penalty=5.0, weights=weights)
-
-        estr = MEstimator(psi, init=[5, 1, 1])
-        estr.estimate(solver='lm')
-        wridge = sm.WLS(yvals, Xvals, weights=weights).fit_regularized(L1_wt=0., alpha=5. / Xvals.shape[0])
-
-        # Checking mean estimate
-        npt.assert_allclose(estr.theta,
-                            np.asarray(wridge.params),
-                            atol=1e-6)
-
-        # Testing array of penalty terms
-        def psi(theta):
-            return ee_ridge_regression(theta, X=Xvals, y=yvals, model='linear', penalty=[0., 5., 2.], weights=weights)
-
-        estr = MEstimator(psi, init=[5, 1, 1])
-        estr.estimate(solver='lm')
-        wridge = sm.WLS(yvals, Xvals, weights=weights).fit_regularized(L1_wt=0.,
-                                                                       alpha=np.array([0., 5., 2.]) / Xvals.shape[0])
-
-        # Checking mean estimate
-        npt.assert_allclose(estr.theta,
-                            np.asarray(wridge.params),
-                            atol=1e-6)
-
-    def test_additive_ols(self):
-        n = 1000
-        data = pd.DataFrame()
-        data['x1'] = np.random.normal(size=n)
-        data['x2'] = np.random.normal(scale=0.1, size=n)
-        data['c'] = 1
-        data['y'] = 5 + data['x1'] + data['x2'] + 0.01*data['x2'] + np.random.normal(size=n)
-        Xvals = np.asarray(data[['c', 'x1', 'x2']])
-        yvals = np.asarray(data['y'])
-        spec = [None, {"knots": [-1, 0, 1], "penalty": 3}, {"knots": [-2, -1, 0, 1, 2], "penalty": 5}]
-
-        # Testing array of penalty terms
-        def psi(theta):
-            return ee_additive_regression(theta, X=Xvals, y=yvals, model='linear', specifications=spec)
-
-        estr = MEstimator(psi, init=[5, 1, 0, 0, 1, 0, 0, 0, 0])
-        estr.estimate(solver='lm')
-
-        design, penalty = additive_design_matrix(X=Xvals, specifications=spec, return_penalty=True)
-        ridge = sm.OLS(yvals, design).fit_regularized(L1_wt=0., alpha=np.array(penalty)/Xvals.shape[0])
-
-        # Checking mean estimate
-        npt.assert_allclose(estr.theta,
-                            np.asarray(ridge.params),
-                            atol=1e-6)
-
-    def test_additive_wls(self):
-        n = 1000
-        data = pd.DataFrame()
-        data['x1'] = np.random.normal(size=n)
-        data['x2'] = np.random.normal(scale=0.1, size=n)
-        data['c'] = 1
-        data['y'] = 5 + data['x1'] + data['x2'] + 0.01*data['x2'] + np.random.normal(size=n)
-        Xvals = np.asarray(data[['c', 'x1', 'x2']])
-        yvals = np.asarray(data['y'])
-        spec = [None, {"knots": [-1, 0, 1], "penalty": 3}, {"knots": [-2, -1, 0, 1, 2], "penalty": 5}]
-        weights = np.random.uniform(0.1, 2.5, size=n)
-
-        # Testing array of penalty terms
-        def psi(theta):
-            return ee_additive_regression(theta, X=Xvals, y=yvals, model='linear',
-                                          specifications=spec, weights=weights)
-
-        estr = MEstimator(psi, init=[5, 1, 0, 0, 1, 0, 0, 0, 0])
-        estr.estimate(solver='lm')
-
-        design, penalty = additive_design_matrix(X=Xvals, specifications=spec, return_penalty=True)
-        ridge = sm.WLS(yvals, design, weights=weights).fit_regularized(L1_wt=0.,
-                                                                       alpha=np.array(penalty)/Xvals.shape[0])
-
-        # Checking mean estimate
-        npt.assert_allclose(estr.theta,
-                            np.asarray(ridge.params),
-                            atol=1e-6)
-
-    def test_logistic(self):
-        n = 1000
-        data = pd.DataFrame()
-        data['X'] = np.random.normal(size=n)
-        data['Z'] = np.random.normal(size=n)
-        data['Y'] = np.random.binomial(n=1, p=logistic.cdf(0.5 + 2*data['X'] - 1*data['Z']), size=n)
-        data['C'] = 1
-
-        def psi_builtin_regression(theta):
-            return ee_regression(theta,
-                                 X=data[['C', 'X', 'Z']], y=data['Y'],
-                                 model='logistic')
-
-        mpee = MEstimator(psi_builtin_regression, init=[0., 0., 0.])
-        mpee.estimate()
-
-        # Comparing to statsmodels GLM (with robust covariance)
-        glm = smf.glm("Y ~ X + Z", data, family=sm.families.Binomial()).fit(cov_type="HC1")
-
-        # Checking mean estimate
-        npt.assert_allclose(mpee.theta,
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mpee.variance,
-                            np.asarray(glm.cov_params()),
-                            atol=1e-6)
-
-        # Checking confidence interval estimates
-        npt.assert_allclose(mpee.confidence_intervals(),
-                            np.asarray(glm.conf_int()),
-                            atol=1e-6)
-
-    def test_logistic_offset(self):
-        n = 1000
-        data = pd.DataFrame()
-        data['X'] = np.random.normal(size=n)
-        data['Z'] = np.random.normal(size=n)
-        data['Y'] = np.random.binomial(n=1, p=logistic.cdf(0.5 + 2*data['X'] - 1*data['Z']), size=n)
-        data['C'] = 1
-        data['off'] = np.random.uniform(0.4, 0.6, size=n)
-
-        def psi_builtin_regression(theta):
-            return ee_regression(theta,
-                                 X=data[['C', 'X', 'Z']], y=data['Y'],
-                                 model='logistic', offset=data['off'])
-
-        mpee = MEstimator(psi_builtin_regression, init=[0., 0., 0.])
-        mpee.estimate()
-
-        # Comparing to statsmodels GLM (with robust covariance)
-        glm = smf.glm("Y ~ X + Z", data, family=sm.families.Binomial(), offset=data['off']).fit(cov_type="HC1")
-
-        # Checking mean estimate
-        npt.assert_allclose(mpee.theta,
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mpee.variance,
-                            np.asarray(glm.cov_params()),
-                            atol=1e-6)
-
-        # Checking confidence interval estimates
-        npt.assert_allclose(mpee.confidence_intervals(),
-                            np.asarray(glm.conf_int()),
-                            atol=1e-6)
-
-    def test_weighted_logistic(self):
-        """Tests weighted logistic regression by-hand with a single estimating equation.
-        """
-        n = 500
-        data = pd.DataFrame()
-        data['X'] = np.random.normal(size=n)
-        data['Z'] = np.random.normal(size=n)
-        data['Y'] = np.random.binomial(n=1, p=logistic.cdf(0.5 + 2*data['X'] - 1*data['Z']), size=n)
-        data['C'] = 1
-        data['w'] = np.random.uniform(1, 10, size=n)
-
-        def psi_regression(theta):
-            return ee_regression(theta,
-                                 X=data[['C', 'X', 'Z']], y=data['Y'],
-                                 model='logistic', weights=data['w'])
-
-        mestimator = MEstimator(psi_regression, init=[0., 2., -1.])
-        mestimator.estimate(solver='lm')
-
-        # Comparing to statsmodels GLM (with robust covariance)
-        glm = smf.glm("Y ~ X + Z", data, freq_weights=data['w'],
-                      family=sm.families.Binomial()).fit(cov_type="cluster",
-                                                         cov_kwds={"groups": data.index,
-                                                                   "use_correction": False})
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta,
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mestimator.variance,
-                            np.asarray(glm.cov_params()),
-                            atol=1e-6)
-
-        # Checking confidence interval estimates
-        npt.assert_allclose(mestimator.confidence_intervals(),
-                            np.asarray(glm.conf_int()),
-                            atol=1e-6)
-
-    def test_ridge_logistic(self):
-        """Tests ridge logistic regression by-hand with a single estimating equation.
-        """
-        n = 1000
-        data = pd.DataFrame()
-        data['X'] = np.random.normal(size=n)
-        data['Z'] = np.random.normal(size=n)
-        data['Y'] = np.random.binomial(n=1, p=logistic.cdf(0.5 + 2*data['X'] - 1*data['Z']), size=n)
-        data['C'] = 1
-        Xvals = np.asarray(data[['C', 'X', 'Z']])
-        yvals = np.asarray(data['Y'])
-
-        def psi_regression(theta):
-            return ee_ridge_regression(theta,
-                                       X=Xvals, y=yvals,
-                                       model='logistic', penalty=0.5, weights=None)
-
-        mestimator = MEstimator(psi_regression, init=[0., 0., 0.])
-        mestimator.estimate(solver='lm', tolerance=1e-12)
-
-        f = sm.families.Binomial()
-        lgt = sm.GLM(yvals, Xvals, family=f).fit_regularized(L1_wt=0., alpha=0.5 / Xvals.shape[0], tol=1e-12)
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta,
-                            np.asarray(lgt.params),
-                            atol=5e-4)
-
-        def psi_regression(theta):
-            return ee_ridge_regression(theta,
-                                       X=Xvals, y=yvals,
-                                       model='logistic', penalty=5., weights=None)
-
-        mestimator = MEstimator(psi_regression, init=[0., 0., 0.])
-        mestimator.estimate(solver='hybr', tolerance=1e-12)
-
-        f = sm.families.Binomial()
-        lgt = sm.GLM(yvals, Xvals, family=f).fit_regularized(L1_wt=0., alpha=5. / Xvals.shape[0], tol=1e-12)
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta,
-                            np.asarray(lgt.params),
-                            atol=5e-4)
-
-        def psi_regression(theta):
-            return ee_ridge_regression(theta,
-                                       X=Xvals, y=yvals,
-                                       model='logistic', penalty=[0., 5., 2.], weights=None)
-
-        mestimator = MEstimator(psi_regression, init=[0., 0., 0.])
-        mestimator.estimate(solver='hybr', tolerance=1e-12)
-
-        f = sm.families.Binomial()
-        lgt = sm.GLM(yvals, Xvals, family=f).fit_regularized(L1_wt=0., alpha=np.array([0., 5., 2.]) / Xvals.shape[0])
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta,
-                            np.asarray(lgt.params),
-                            atol=5e-4)
-
-    def test_additive_logistic(self):
-        n = 1000
-        data = pd.DataFrame()
-        data['X'] = np.random.normal(size=n)
-        data['Z'] = np.random.normal(size=n)
-        data['Y'] = np.random.binomial(n=1, p=logistic.cdf(0.5 + 2*data['X'] - 1*data['Z']), size=n)
-        data['C'] = 1
-        Xvals = np.asarray(data[['C', 'X', 'Z']])
-        yvals = np.asarray(data['Y'])
-        spec = [None, {"knots": [-1, 0, 1], "penalty": 3}, {"knots": [-2, -1, 0, 1, 2], "penalty": 5}]
-
-        def psi_regression(theta):
-            return ee_additive_regression(theta, X=Xvals, y=yvals,
-                                          model='logistic',
-                                          specifications=spec)
-
-        mestimator = MEstimator(psi_regression, init=[0., 2., 0., 0., 1., 0., 0., 0., 0.])
-        mestimator.estimate(solver='lm', tolerance=1e-14)
-
-        design, penalty = additive_design_matrix(X=Xvals, specifications=spec, return_penalty=True)
-        f = sm.families.Binomial()
-        lgt = sm.GLM(yvals, design, family=f).fit_regularized(L1_wt=0., alpha=np.array(penalty)/Xvals.shape[0],
-                                                              tol=1e-14)
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta,
-                            np.asarray(lgt.params),
-                            atol=1e-3)
-
-    def test_poisson(self):
-        """Tests Poisson regression by-hand with a single estimating equation.
-        """
-        np.random.seed(20212345)
-        n = 500
-        data = pd.DataFrame()
-        data['X'] = np.random.normal(size=n)
-        data['Z'] = np.random.normal(size=n)
-        data['Y'] = np.random.poisson(lam=np.exp(1 + 2*data['X'] - 1*data['Z']), size=n)
-        data['C'] = 1
-
-        def psi_regression(theta):
-            return ee_regression(theta,
-                                 X=data[['C', 'X', 'Z']], y=data['Y'],
-                                 model='poisson')
-
-        mestimator = MEstimator(psi_regression, init=[0., 0., 0.])
-        mestimator.estimate(solver='lm')
-
-        # Comparing to statsmodels GLM (with robust covariance)
-        glm = smf.glm("Y ~ X + Z", data, family=sm.families.Poisson()).fit(cov_type="HC1")
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta,
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mestimator.variance,
-                            np.asarray(glm.cov_params()),
-                            atol=1e-6)
-
-        # Checking confidence interval estimates
-        npt.assert_allclose(mestimator.confidence_intervals(),
-                            np.asarray(glm.conf_int()),
-                            atol=1e-6)
-
-    def test_weighted_poisson(self):
-        """Tests weighted Poisson regression by-hand with a single estimating equation.
-        """
-        np.random.seed(1234)
-        n = 500
-        data = pd.DataFrame()
-        data['X'] = np.random.normal(size=n)
-        data['Z'] = np.random.normal(size=n)
-        data['Y'] = np.random.poisson(lam=np.exp(1 + 2*data['X'] - 1*data['Z']), size=n)
-        data['C'] = 1
-        data['w'] = np.random.uniform(1, 3, size=n)
-
-        def psi_regression(theta):
-            return ee_regression(theta,
-                                 X=data[['C', 'X', 'Z']], y=data['Y'],
-                                 model='poisson', weights=data['w'])
-
-        mestimator = MEstimator(psi_regression, init=[0., 0., 0.])
-        mestimator.estimate(solver='lm')
-
-        # Comparing to statsmodels GLM (with robust covariance)
-        glm = smf.glm("Y ~ X + Z", data, freq_weights=data['w'],
-                      family=sm.families.Poisson()).fit(cov_type="cluster",
-                                                        cov_kwds={"groups": data.index,
-                                                                  "use_correction": False})
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta,
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mestimator.variance,
-                            np.asarray(glm.cov_params()),
-                            atol=1e-6)
-
-        # Checking confidence interval estimates
-        npt.assert_allclose(mestimator.confidence_intervals(),
-                            np.asarray(glm.conf_int()),
-                            atol=1e-6)
-
-    def test_ridge_poisson(self):
-        """Tests ridge Poisson regression by-hand with a single estimating equation.
-        """
-        n = 1000
-        data = pd.DataFrame()
-        data['X'] = np.random.normal(size=n)
-        data['Z'] = np.random.normal(size=n)
-        data['Y'] = np.random.poisson(lam=np.exp(1 + 2*data['X'] - 1*data['Z']), size=n)
-        data['C'] = 1
-        Xvals = np.asarray(data[['C', 'X', 'Z']])
-        yvals = np.asarray(data['Y'])
-
-        def psi_regression(theta):
-            return ee_ridge_regression(theta,
-                                       X=Xvals, y=yvals,
-                                       model='poisson', penalty=0.5, weights=None)
-
-        mestimator = MEstimator(psi_regression, init=[0., 0., 0.])
-        mestimator.estimate(solver='lm', tolerance=1e-12)
-
-        f = sm.families.Poisson()
-        lgt = sm.GLM(yvals, Xvals, family=f).fit_regularized(L1_wt=0., alpha=0.5 / Xvals.shape[0])
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta,
-                            np.asarray(lgt.params),
-                            atol=1e-6)
-
-        def psi_regression(theta):
-            return ee_ridge_regression(theta,
-                                       X=Xvals, y=yvals,
-                                       model='poisson', penalty=2.5, weights=None)
-
-        mestimator = MEstimator(psi_regression, init=[0., 0., 0.])
-        mestimator.estimate(solver='lm', tolerance=1e-12)
-
-        lgt = sm.GLM(yvals, Xvals, family=f).fit_regularized(L1_wt=0., alpha=2.5 / Xvals.shape[0])
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta,
-                            np.asarray(lgt.params),
-                            atol=1e-6)
-
-        def psi_regression(theta):
-            return ee_ridge_regression(theta,
-                                       X=Xvals, y=yvals,
-                                       model='poisson', penalty=[0., 5., 2.5], weights=None)
-
-        mestimator = MEstimator(psi_regression, init=[0., 0., 0.])
-        mestimator.estimate(solver='lm', tolerance=1e-12)
-
-        lgt = sm.GLM(yvals, Xvals, family=f).fit_regularized(L1_wt=0., alpha=np.asarray([0., 5., 2.5]) / Xvals.shape[0])
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta,
-                            np.asarray(lgt.params),
-                            atol=1e-6)
-
-    def test_ridge_wpoisson(self):
-        """Tests weighted ridge Poisson regression by-hand with a single estimating equation.
-        """
-        n = 1000
-        data = pd.DataFrame()
-        data['X'] = np.random.normal(size=n)
-        data['Z'] = np.random.normal(size=n)
-        data['Y'] = np.random.poisson(lam=np.exp(1 + 2*data['X'] - 1*data['Z']), size=n)
-        data['C'] = 1
-        Xvals = np.asarray(data[['C', 'X', 'Z']])
-        yvals = np.asarray(data['Y'])
-        weights = np.random.uniform(0.5, 2, size=n)
-
-        def psi_regression(theta):
-            return ee_ridge_regression(theta,
-                                       X=Xvals, y=yvals,
-                                       model='poisson', penalty=0.5, weights=weights)
-
-        mestimator = MEstimator(psi_regression, init=[0., 0., 0.])
-        mestimator.estimate(solver='lm', tolerance=1e-12)
-
-        f = sm.families.Poisson()
-        lgt = sm.GLM(yvals, Xvals, family=f, freq_weights=weights).fit_regularized(L1_wt=0., alpha=0.5 / Xvals.shape[0])
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta,
-                            np.asarray(lgt.params),
-                            atol=1e-5)
-
-        def psi_regression(theta):
-            return ee_ridge_regression(theta,
-                                       X=Xvals, y=yvals,
-                                       model='poisson', penalty=2.5, weights=weights)
-
-        mestimator = MEstimator(psi_regression, init=[0., 0., 0.])
-        mestimator.estimate(solver='lm', tolerance=1e-12)
-
-        lgt = sm.GLM(yvals, Xvals, family=f, freq_weights=weights).fit_regularized(L1_wt=0., alpha=2.5 / Xvals.shape[0])
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta,
-                            np.asarray(lgt.params),
-                            atol=2e-5)
-
-        def psi_regression(theta):
-            return ee_ridge_regression(theta,
-                                       X=Xvals, y=yvals,
-                                       model='poisson', penalty=[0., 5., 2.5], weights=weights)
-
-        mestimator = MEstimator(psi_regression, init=[0., 0., 0.])
-        mestimator.estimate(solver='lm', tolerance=1e-12)
-
-        lgt = sm.GLM(yvals, Xvals, family=f, freq_weights=weights).fit_regularized(L1_wt=0.,
-                                                                                   alpha=np.asarray([0., 5., 2.5]
-                                                                                                    ) / Xvals.shape[0])
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta,
-                            np.asarray(lgt.params),
-                            atol=5e-4)
-
-    def test_additive_poisson(self):
-        n = 1000
-        data = pd.DataFrame()
-        data['X'] = np.random.normal(size=n)
-        data['Z'] = np.random.normal(size=n)
-        data['Y'] = np.random.poisson(lam=np.exp(1 + 2*data['X'] - 1*data['Z']), size=n)
-        data['C'] = 1
-        Xvals = np.asarray(data[['C', 'X', 'Z']])
-        yvals = np.asarray(data['Y'])
-        spec = [None, {"knots": [-1, 0, 1], "penalty": 3}, {"knots": [-2, -1, 0, 1, 2], "penalty": 5}]
-
-        def psi_regression(theta):
-            return ee_additive_regression(theta, X=Xvals, y=yvals,
-                                          model='poisson',
-                                          specifications=spec)
-
-        mestimator = MEstimator(psi_regression, init=[0., 2., 0., 0., 1., 0., 0., 0., 0.])
-        mestimator.estimate(solver='lm', tolerance=1e-12)
-
-        design, penalty = additive_design_matrix(X=Xvals, specifications=spec, return_penalty=True)
-        f = sm.families.Poisson()
-        lgt = sm.GLM(yvals, design, family=f).fit_regularized(L1_wt=0., alpha=np.array(penalty)/Xvals.shape[0])
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta,
-                            np.asarray(lgt.params),
-                            atol=1e-5)
-
-    def test_glm_normal_identity(self):
-        d = pd.DataFrame()
-        d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
-        d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
-        d['Y'] = [1, 5, 1, 9, -1, 4, 3, 3, 1, -2, 4, -2, 3, 6, 6, 8, 7, 1, -2, 5]
-        d['I'] = 1
-
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
-                          distribution='normal', link='identity')
-
-        mestr = MEstimator(psi, init=[0., 0., 0.])
-        mestr.estimate(solver='lm')
-
-        fam = sm.families.Gaussian(sm.families.links.identity())
-        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1")
-
-        # Checking mean estimate
-        npt.assert_allclose(mestr.theta,
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mestr.variance,
-                            np.asarray(glm.cov_params()),
-                            atol=1e-6)
-
-    def test_glm_normal_log(self):
-        d = pd.DataFrame()
-        d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
-        d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
-        d['Y'] = [1, 5, 1, 9, -1, 4, 3, 3, 1, -2, 4, -2, 3, 6, 6, 8, 7, 1, -2, 5]
-        d['Y'] = d['Y'] + 5
-        d['I'] = 1
-
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
-                          distribution='normal', link='log')
-
-        mestr = MEstimator(psi, init=[2., 0., 0.])
-        mestr.estimate(solver='lm', maxiter=5000)
-
-        fam = sm.families.Gaussian(sm.families.links.log())
-        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1")
-
-        # Checking mean estimate
-        npt.assert_allclose(mestr.theta,
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mestr.variance,
-                            np.asarray(glm.cov_params()),
-                            atol=1e-6)
-
-    def test_glm_poisson_identity(self):
-        d = pd.DataFrame()
-        d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
-        d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
-        d['Y'] = [1, 5, 1, 9, 1, 4, 3, 3, 1, 2, 4, 2, 3, 6, 6, 8, 7, 1, 2, 5]
-        d['I'] = 1
-
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
-                          distribution='poisson', link='identity')
-
-        mestr = MEstimator(psi, init=[1., 0., 0.])
-        mestr.estimate(solver='lm', maxiter=5000)
-
-        fam = sm.families.Poisson(sm.families.links.identity())
-        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1")
-
-        # Checking mean estimate
-        npt.assert_allclose(mestr.theta,
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mestr.variance,
-                            np.asarray(glm.cov_params()),
-                            atol=1e-6)
-
-    def test_glm_poisson_log(self):
-        d = pd.DataFrame()
-        d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
-        d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
-        d['Y'] = [1, 5, 1, 9, 1, 4, 3, 3, 1, 2, 4, 2, 3, 6, 6, 8, 7, 1, 2, 5]
-        d['I'] = 1
-
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
-                          distribution='poisson', link='log')
-
-        mestr = MEstimator(psi, init=[0., 0., 0.])
-        mestr.estimate(solver='lm', maxiter=5000)
-
-        fam = sm.families.Poisson(sm.families.links.log())
-        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1")
-
-        # Checking mean estimate
-        npt.assert_allclose(mestr.theta,
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mestr.variance,
-                            np.asarray(glm.cov_params()),
-                            atol=1e-6)
-
-    def test_glm_poisson_sqrt(self):
-        d = pd.DataFrame()
-        d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
-        d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
-        d['Y'] = [1, 5, 1, 9, 1, 4, 3, 3, 1, 2, 4, 2, 3, 6, 6, 8, 7, 1, 2, 5]
-        d['I'] = 1
-
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
-                          distribution='poisson', link='sqrt')
-
-        mestr = MEstimator(psi, init=[2., 0., 0.])
-        mestr.estimate(solver='lm', maxiter=5000)
-
-        fam = sm.families.Poisson(sm.families.links.sqrt())
-        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1")
-
-        # Checking mean estimate
-        npt.assert_allclose(mestr.theta,
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mestr.variance,
-                            np.asarray(glm.cov_params()),
-                            atol=1e-6)
-
-    def test_glm_binomial_logit(self):
-        d = pd.DataFrame()
-        d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
-        d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
-        d['Y'] = [1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 0, 0]
-        d['I'] = 1
-
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
-                          distribution='binomial', link='logit')
-
-        mestr = MEstimator(psi, init=[0., 0., 0.])
-        mestr.estimate(solver='lm', maxiter=5000)
-
-        fam = sm.families.Binomial(sm.families.links.logit())
-        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1")
-
-        # Checking mean estimate
-        npt.assert_allclose(mestr.theta,
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mestr.variance,
-                            np.asarray(glm.cov_params()),
-                            atol=1e-6)
-
-    def test_glm_binomial_log(self):
-        d = pd.DataFrame()
-        d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
-        d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
-        d['Y'] = [1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 0, 0]
-        d['I'] = 1
-
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
-                          distribution='binomial', link='log')
-
-        mestr = MEstimator(psi, init=[-.9, 0., 0.])
-        mestr.estimate(solver='lm', maxiter=5000)
-
-        fam = sm.families.Binomial(sm.families.links.log())
-        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1", tol=1e-12)
-
-        # Checking mean estimate
-        npt.assert_allclose(mestr.theta,
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mestr.variance,
-                            np.asarray(glm.cov_params()),
-                            atol=1e-6)
-
-    def test_glm_binomial_identity(self):
-        d = pd.DataFrame()
-        d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
-        d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
-        d['Y'] = [1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 0, 0]
-        d['I'] = 1
-
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
-                          distribution='binomial', link='identity')
-
-        mestr = MEstimator(psi, init=[.2, 0., 0.])
-        mestr.estimate(solver='lm', maxiter=5000)
-
-        fam = sm.families.Binomial(sm.families.links.identity())
-        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1", tol=1e-12)
-
-        # Checking mean estimate
-        npt.assert_allclose(mestr.theta,
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mestr.variance,
-                            np.asarray(glm.cov_params()),
-                            atol=1e-6)
-
-    def test_glm_binomial_probit(self):
-        d = pd.DataFrame()
-        d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
-        d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
-        d['Y'] = [1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 0, 0]
-        d['I'] = 1
-
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
-                          distribution='binomial', link='probit')
-
-        mestr = MEstimator(psi, init=[0., 0., 0.])
-        mestr.estimate(solver='lm', maxiter=5000)
-
-        fam = sm.families.Binomial(sm.families.links.probit())
-        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1", tol=1e-12)
-
-        # Checking mean estimate
-        npt.assert_allclose(mestr.theta,
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mestr.variance,
-                            np.asarray(glm.cov_params()),
-                            atol=1e-6)
-
-    def test_glm_binomial_cauchy(self):
-        d = pd.DataFrame()
-        d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
-        d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
-        d['Y'] = [1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 0, 0]
-        d['I'] = 1
-
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
-                          distribution='binomial', link='cauchy')
-
-        mestr = MEstimator(psi, init=[0., 0., 0.])
-        mestr.estimate(solver='lm', maxiter=5000)
-
-        fam = sm.families.Binomial(sm.families.links.cauchy())
-        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1", tol=1e-12)
-
-        # Checking mean estimate
-        npt.assert_allclose(mestr.theta,
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mestr.variance,
-                            np.asarray(glm.cov_params()),
-                            atol=1e-6)
-
-    def test_glm_binomial_cloglog(self):
-        d = pd.DataFrame()
-        d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
-        d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
-        d['Y'] = [1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 0, 0]
-        d['I'] = 1
-
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
-                          distribution='binomial', link='cloglog')
-
-        mestr = MEstimator(psi, init=[0., 0., 0.])
-        mestr.estimate(solver='lm', maxiter=5000)
-
-        fam = sm.families.Binomial(sm.families.links.cloglog())
-        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1", tol=1e-12)
-
-        # Checking mean estimate
-        npt.assert_allclose(mestr.theta,
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mestr.variance,
-                            np.asarray(glm.cov_params()),
-                            atol=1e-6)
-
-    def test_glm_binomial_loglog(self):
-        d = pd.DataFrame()
-        d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
-        d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
-        d['Y'] = [1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 0, 0]
-        d['I'] = 1
-
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
-                          distribution='binomial', link='loglog')
-
-        mestr = MEstimator(psi, init=[0., 0., 0.])
-        mestr.estimate(solver='lm', maxiter=5000)
-
-        fam = sm.families.Binomial(sm.families.links.loglog())
-        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1", tol=1e-12)
-
-        # Checking mean estimate
-        npt.assert_allclose(mestr.theta,
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mestr.variance,
-                            np.asarray(glm.cov_params()),
-                            atol=1e-6)
-
-    def test_glm_binomial_logit_offset(self):
-        d = pd.DataFrame()
-        d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
-        d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
-        d['Y'] = [1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 0, 0]
-        d['I'] = 1
-        d['F'] = [1, 1, 1, 2, 2, 2, 3, 1, 1, 1, 1, 2, 1, 1, 1, 2, 1, 1, 3, 3]
-
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
-                          distribution='binomial', link='logit',
-                          offset=d['F'])
-
-        mestr = MEstimator(psi, init=[0., 0., 0.])
-        mestr.estimate(solver='lm', maxiter=5000)
-
-        fam = sm.families.Binomial(sm.families.links.logit())
-        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], offset=d['F'], family=fam).fit(cov_type="HC1", tol=1e-12)
-
-        # Checking mean estimate
-        npt.assert_allclose(mestr.theta,
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mestr.variance,
-                            np.asarray(glm.cov_params()),
-                            atol=1e-6)
-
-    def test_glm_gamma_log(self):
-        # Example data comes from R's MASS library
-        d = pd.DataFrame()
-        d['X'] = np.log([5, 10, 15, 20, 30, 40, 60, 80, 100])
-        d['Y'] = [118, 58, 42, 35, 27, 25, 21, 19, 18]
-        d['I'] = 1
-
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X']], y=d['Y'],
-                          distribution='gamma', link='log')
-
-        mestr = MEstimator(psi, init=[0., 0., 1.])
-        mestr.estimate(solver='lm', maxiter=5000)
-
-        # Log-Gamma with statsmodels (only includes scale parameters)
-        fam = sm.families.Gamma(sm.families.links.Log())
-        glm = sm.GLM(d['Y'], d[['I', 'X']], family=fam).fit(cov_type="HC1", tol=1e-12)
-
-        # Checking mean estimate
-        npt.assert_allclose(mestr.theta[0:2],
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mestr.variance[0:2, 0:2],
-                            np.asarray(glm.cov_params()),
-                            atol=1e-6)
-
-        # Previously solved log Gamma shape parameter using MASS
-        # library(MASS)
-        # clotting <- data.frame(
-        #     u = c(5,10,15,20,30,40,60,80,100),
-        #     lot1 = c(118,58,42,35,27,25,21,19,18),
-        #     lot2 = c(69,35,26,21,18,16,13,12,12))
-        # clot1 <- glm(lot1 ~ log(u), data = clotting, family = Gamma(link='log'))
-        # summary(clot1)
-        # gamma.shape(clot1)
-        alpha_param = np.log(55.51389)
-
-        # Checking SNM parameters
-        npt.assert_allclose(mestr.theta[2],
-                            alpha_param,
-                            atol=1e-4)
-
-    def test_glm_gamma_identity(self):
-        # Example data comes from R's MASS library
-        d = pd.DataFrame()
-        d['X'] = np.log([5, 10, 15, 20, 30, 40, 60, 80, 100])
-        d['Y'] = [118, 58, 42, 35, 27, 25, 21, 19, 18]
-        d['I'] = 1
-
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X']], y=d['Y'],
-                          distribution='gamma', link='identity')
-
-        mestr = MEstimator(psi, init=[100., -10., 10.])
-        mestr.estimate(solver='lm', maxiter=5000)
-
-        # Log-Gamma with statsmodels (only includes scale parameters)
-        fam = sm.families.Gamma(sm.families.links.identity())
-        glm = sm.GLM(d['Y'], d[['I', 'X']], family=fam).fit(cov_type="HC1", tol=1e-12)
-
-        # Checking mean estimate
-        npt.assert_allclose(mestr.theta[0:2],
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mestr.variance[0:2, 0:2],
-                            np.asarray(glm.cov_params()),
-                            rtol=1e-4)
-
-        # Previously solved log Gamma shape parameter using MASS
-        # library(MASS)
-        # clotting <- data.frame(
-        #     u = c(5,10,15,20,30,40,60,80,100),
-        #     lot1 = c(118,58,42,35,27,25,21,19,18),
-        #     lot2 = c(69,35,26,21,18,16,13,12,12))
-        # clot1 <- glm(lot1 ~ log(u), data = clotting, family = Gamma(link='identity'))
-        # summary(clot1)
-        # gamma.shape(clot1)
-        alpha_param = np.log(14.956340)
-
-        # Checking SNM parameters
-        npt.assert_allclose(mestr.theta[2],
-                            alpha_param,
-                            atol=1e-4)
-
-    def test_glm_gamma_inverse(self):
-        # Example data comes from R's MASS library
-        d = pd.DataFrame()
-        d['X'] = np.log([5, 10, 15, 20, 30, 40, 60, 80, 100])
-        d['Y'] = [118, 58, 42, 35, 27, 25, 21, 19, 18]
-        d['I'] = 1
-
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X']], y=d['Y'],
-                          distribution='gamma', link='inverse')
-
-        mestr = MEstimator(psi, init=[0.1, 0.1, 2.])
-        mestr.estimate(solver='lm', maxiter=5000)
-
-        # Log-Gamma with statsmodels (only includes scale parameters)
-        fam = sm.families.Gamma(sm.families.links.inverse_power())
-        glm = sm.GLM(d['Y'], d[['I', 'X']], family=fam).fit(cov_type="HC1", tol=1e-12)
-
-        # Checking mean estimate
-        npt.assert_allclose(mestr.theta[0:2],
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mestr.variance[0:2, 0:2],
-                            np.asarray(glm.cov_params()),
-                            rtol=1e-5)
-
-        # Previously solved log Gamma shape parameter using MASS
-        # library(MASS)
-        # clotting <- data.frame(
-        #     u = c(5,10,15,20,30,40,60,80,100),
-        #     lot1 = c(118,58,42,35,27,25,21,19,18),
-        #     lot2 = c(69,35,26,21,18,16,13,12,12))
-        # clot1 <- glm(lot1 ~ log(u), data = clotting, family = Gamma(link='inverse'))
-        # summary(clot1)
-        # gamma.shape(clot1)
-        alpha_param = np.log(538.1315)
-
-        # Checking SNM parameters
-        npt.assert_allclose(mestr.theta[2],
-                            alpha_param,
-                            atol=1e-4)
-
-    def test_glm_gamma_log_weighted(self):
-        # Example data comes from R's MASS library
-        d = pd.DataFrame()
-        d['X'] = np.log([5, 10, 15, 20, 30, 40, 60, 80, 100])
-        d['Y'] = [118, 58, 42, 35, 27, 25, 21, 19, 18]
-        d['I'] = 1
-        d['w'] = [1, 2, 1, 3, 1, 6, 1, 2, 3]
-
-        # Using the weights
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X']], y=d['Y'],
-                          distribution='gamma', link='log',
-                          weights=d['w'])
-
-        westr = MEstimator(psi, init=[0., 0., 0.])
-        westr.estimate(solver='lm', maxiter=5000)
-
-        # Using an expanded data frame instead
-        ld = pd.DataFrame(np.repeat(d.values,
-                                    d['w'],
-                                    axis=0),
-                          columns=d.columns)
-
-        def psi(theta):
-            return ee_glm(theta, X=ld[['I', 'X']], y=ld['Y'],
-                          distribution='gamma', link='log')
-
-        uestr = MEstimator(psi, init=[0., 0., 0.])
-        uestr.estimate(solver='lm', maxiter=5000)
-
-        # Checking mean estimate
-        npt.assert_allclose(westr.theta,
-                            uestr.theta,
-                            atol=1e-6)
-
-    def test_glm_invnormal_identity(self):
-        d = pd.DataFrame()
-        d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
-        d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
-        d['Y'] = [1, 5, 1, 9, 1, 4, 3, 3, 1, 2, 4, 2, 3, 6, 6, 8, 7, 1, 2, 5]
-        d['I'] = 1
-
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
-                          distribution='inverse_normal', link='identity')
-
-        mestr = MEstimator(psi, init=[2., 0., 0.])
-        mestr.estimate(solver='lm', maxiter=5000)
-
-        fam = sm.families.InverseGaussian(sm.families.links.identity())
-        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1")
-
-        # Checking mean estimate
-        npt.assert_allclose(mestr.theta,
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mestr.variance,
-                            np.asarray(glm.cov_params()),
-                            atol=1e-6)
-
-    def test_glm_invnormal_log(self):
-        d = pd.DataFrame()
-        d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
-        d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
-        d['Y'] = [1, 5, 1, 9, 1, 4, 3, 3, 1, 2, 4, 2, 3, 6, 6, 8, 7, 1, 2, 5]
-        d['I'] = 1
-
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
-                          distribution='inverse_normal', link='log',
-                          hyperparameter=1.5)
-
-        mestr = MEstimator(psi, init=[0., 0., 0.])
-        mestr.estimate(solver='lm', maxiter=5000)
-
-        fam = sm.families.InverseGaussian(sm.families.links.log())
-        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1")
-
-        # Checking mean estimate
-        npt.assert_allclose(mestr.theta,
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mestr.variance,
-                            np.asarray(glm.cov_params()),
-                            atol=1e-6)
-
-    def test_glm_tweedie_log(self):
-        d = pd.DataFrame()
-        d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
-        d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
-        d['Y'] = [1, 5, 1, 9, 1, 4, 3, 3, 1, 2, 4, 2, 3, 6, 6, 8, 7, 1, 2, 5]
-        d['I'] = 1
-
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
-                          distribution='tweedie', link='log',
-                          hyperparameter=1.5)
-
-        mestr = MEstimator(psi, init=[2., 0., 0.])
-        mestr.estimate(solver='lm', maxiter=5000)
-
-        fam = sm.families.Tweedie(sm.families.links.log(), var_power=1.5)
-        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1")
-
-        # Checking mean estimate
-        npt.assert_allclose(mestr.theta,
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mestr.variance,
-                            np.asarray(glm.cov_params()),
-                            atol=1e-6)
-
-    def test_glm_tweedie_error(self):
-        d = pd.DataFrame()
-        d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
-        d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
-        d['Y'] = [1, 5, 1, 9, 1, 4, 3, 3, 1, 2, 4, 2, 3, 6, 6, 8, 7, 1, 2, 5]
-        d['I'] = 1
-
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
-                          distribution='tweedie', link='log',
-                          hyperparameter=-1)
-
-        mestr = MEstimator(psi, init=[2., 0., 0.])
-        with pytest.raises(ValueError, match="non-negative"):
-            mestr.estimate(solver='lm', maxiter=5000)
-
-    def test_glm_tweedie_lessthan1(self):
-        d = pd.DataFrame()
-        d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
-        d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
-        d['Y'] = [0, 0, 0, 0, 0, 15, 15, 25, 25, 45, 0, 0, 0, 0, 15, 15, 15, 25, 25, 35]
-        d['I'] = 1
-
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
-                          distribution='tweedie', link='log',
-                          hyperparameter=0.5)
-
-        mestr = MEstimator(psi, init=[2., 0., 0.])
-        mestr.estimate(solver='lm', maxiter=5000)
-
-        fam = sm.families.Tweedie(sm.families.links.log(), var_power=0.5)
-        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1")
-
-        # Checking mean estimate
-        npt.assert_allclose(mestr.theta,
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mestr.variance,
-                            np.asarray(glm.cov_params()),
-                            atol=1e-6)
-
-    def test_glm_tweedie_identity(self):
-        d = pd.DataFrame()
-        d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
-        d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
-        d['Y'] = [1, 5, 1, 9, 1, 4, 3, 3, 1, 2, 4, 2, 3, 6, 6, 8, 7, 1, 2, 5]
-        d['I'] = 1
-
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
-                          distribution='tweedie', link='log',
-                          hyperparameter=1.5)
-
-        mestr = MEstimator(psi, init=[2., 0., 0.])
-        mestr.estimate(solver='lm', maxiter=5000)
-
-        fam = sm.families.Tweedie(sm.families.links.log(), var_power=1.5)
-        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1")
-
-        # Checking mean estimate
-        npt.assert_allclose(mestr.theta,
-                            np.asarray(glm.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mestr.variance,
-                            np.asarray(glm.cov_params()),
-                            atol=1e-6)
-
-    def test_glm_tweedie_poisson(self):
-        d = pd.DataFrame()
-        d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
-        d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
-        d['Y'] = [1, 5, 1, 9, 1, 4, 3, 3, 1, 2, 4, 2, 3, 6, 6, 8, 7, 1, 2, 5]
-        d['I'] = 1
-
-        # Using the weights
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
-                          distribution='tweedie', link='log',
-                          hyperparameter=1)
-
-        testr = MEstimator(psi, init=[0., 0., 0.])
-        testr.estimate(solver='lm', maxiter=5000)
-
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
-                          distribution='poisson', link='log')
-
-        sestr = MEstimator(psi, init=[0., 0., 0.])
-        sestr.estimate(solver='lm', maxiter=5000)
-
-        # Checking mean estimate
-        npt.assert_allclose(testr.theta,
-                            sestr.theta,
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(testr.variance,
-                            sestr.variance,
-                            rtol=1e-6)
-
-    def test_glm_tweedie_gamma(self):
-        d = pd.DataFrame()
-        d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
-        d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
-        d['Y'] = [1, 5, 1, 9, 1, 4, 3, 3, 1, 2, 4, 2, 3, 6, 6, 8, 7, 1, 2, 5]
-        d['I'] = 1
-
-        # Using the weights
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
-                          distribution='tweedie', link='log',
-                          hyperparameter=2)
-
-        testr = MEstimator(psi, init=[0., 0., 0.])
-        testr.estimate(solver='lm', maxiter=5000)
-
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
-                          distribution='gamma', link='log')
-
-        sestr = MEstimator(psi, init=[0., 0., 0., 0.])
-        sestr.estimate(solver='lm', maxiter=5000)
-
-        # Checking mean estimate
-        npt.assert_allclose(testr.theta,
-                            sestr.theta[0:3],
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(testr.variance,
-                            sestr.variance[0:3, 0:3],
-                            atol=1e-6)
-
-    def test_glm_tweedie_invnormal(self):
-        d = pd.DataFrame()
-        d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
-        d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
-        d['Y'] = [1, 5, 1, 9, 1, 4, 3, 3, 1, 2, 4, 2, 3, 6, 6, 8, 7, 1, 2, 5]
-        d['I'] = 1
-
-        # Using the weights
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
-                          distribution='tweedie', link='log',
-                          hyperparameter=3)
-
-        testr = MEstimator(psi, init=[0., 0., 0.])
-        testr.estimate(solver='lm', maxiter=5000)
-
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
-                          distribution='inverse_normal', link='log')
-
-        sestr = MEstimator(psi, init=[0., 0., 0.])
-        sestr.estimate(solver='lm', maxiter=5000)
-
-        # Checking mean estimate
-        npt.assert_allclose(testr.theta,
-                            sestr.theta,
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(testr.variance,
-                            sestr.variance,
-                            rtol=1e-6)
-
-    def test_glm_nb_log(self):
-        d = pd.DataFrame()
-        d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
-        d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
-        d['Y'] = [0, 0, 0, 0, 0, 15, 15, 25, 25, 45, 0, 0, 0, 0, 15, 15, 15, 25, 25, 35]
-        d['I'] = 1
-
-        # M-estimation negative binomial
-        def psi(theta):
-            ef_glm = ee_glm(theta[:-1], X=d[['I', 'X', 'Z']], y=d['Y'],
-                            distribution='nb', link='log')
-            # Transform with exponentiation to compare back to statsmodels
-            ef_ta = np.ones(d.shape[0])*(np.exp(theta[-2]) - theta[-1])
-            return np.vstack([ef_glm, ef_ta])
-
-        mestr = MEstimator(psi, init=[0., 0., 0., -2., 1.])
-        mestr.estimate(solver='lm', maxiter=5000)
-
-        # Negative Binomial using statsmodels
-        nb = sm.NegativeBinomial(d['Y'], d[['I', 'X', 'Z']],
-                                 loglike_method='nb2').fit(cov_type="HC1",
-                                                           tol=1e-10,
-                                                           method='newton')
-
-        # Checking mean estimate
-        npt.assert_allclose(list(mestr.theta[:3]) + [mestr.theta[-1], ],
-                            np.asarray(nb.params),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        cov_mat = np.asarray(nb.cov_params())
-        npt.assert_allclose(mestr.variance[:3, :3],
-                            np.asarray(cov_mat[:3, :3]),
-                            atol=1e-6)
-
-        # Checking covariance for nuisance parameters (since there is an extra transform)
-        npt.assert_allclose(mestr.variance[-1, -1],
-                            np.asarray(cov_mat[-1, -1]),
-                            atol=1e-4)
-        npt.assert_allclose(mestr.variance[0, -1],
-                            np.asarray(cov_mat[0, -1]),
-                            atol=1e-4)
-        npt.assert_allclose(mestr.variance[1, -1],
-                            np.asarray(cov_mat[1, -1]),
-                            atol=1e-4)
-        npt.assert_allclose(mestr.variance[2, -1],
-                            np.asarray(cov_mat[2, -1]),
-                            atol=1e-4)
-
-    def test_glm_nb_identity(self):
-        d = pd.DataFrame()
-        d['X'] = [1, -1, 0, 1, 2, 1, -2, -1, 0, 3, -3, 1, 1, -1, -1, -2, 2, 0, -1, 0]
-        d['Z'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
-        d['Y'] = [0, 0, 0, 0, 0, 15, 15, 25, 25, 45, 0, 0, 0, 0, 15, 15, 15, 25, 25, 35]
-        d['I'] = 1
-
-        # M-estimation negative binomial
-        def psi(theta):
-            return ee_glm(theta, X=d[['I', 'X', 'Z']], y=d['Y'],
-                          distribution='nb', link='identity')
-
-        mestr = MEstimator(psi, init=[20., 0., 0., 1.])
-        mestr.estimate(solver='lm', maxiter=5000)
-
-        # Negative Binomial using statsmodels
-        fam = sm.families.NegativeBinomial(sm.families.links.identity(), alpha=np.exp(mestr.theta[-1]))
-        glm = sm.GLM(d['Y'], d[['I', 'X', 'Z']], family=fam).fit(cov_type="HC1", tol=1e-12)
-
-        # Checking mean estimate
-        npt.assert_allclose(mestr.theta[:3],
-                            np.asarray(glm.params),
-                            atol=1e-5)
-
-        # Checking variance estimates
-        # Can't check variances since statsmodels NB ignores the uncertainty in alpha
-
-    def test_elasticnet(self):
-        n = 1000
-        data = pd.DataFrame()
-        data['X'] = np.random.normal(size=n)
-        data['Z'] = np.random.normal(size=n)
-        data['Y'] = 1 + 2*data['X'] - 1*data['Z'] + np.random.normal(size=n)
-        data['C'] = 1
-        Xvals = np.asarray(data[['C', 'X', 'Z']])
-        yvals = np.asarray(data['Y'])
-
-        def psi_ridge(theta):
-            return ee_ridge_regression(theta, X=Xvals, y=yvals,
-                                       model='linear',
-                                       penalty=[0., 5., 2.])
-
-        ridge = MEstimator(psi_ridge, init=[0., 0., 0.])
-        ridge.estimate(solver='lm')
-
-        def psi_elastic(theta):
-            return ee_elasticnet_regression(theta, X=Xvals, y=yvals,
-                                            model='linear',
-                                            penalty=[0., 5., 2.], ratio=0)
-
-        elastic = MEstimator(psi_elastic, init=[0., 0., 0.])
-        elastic.estimate(solver='lm')
-
-        # Checking mean estimate
-        npt.assert_allclose(ridge.theta,
-                            elastic.theta,
-                            atol=1e-5)
-
-    def test_elasticnet_offset(self):
-        n = 1000
-        data = pd.DataFrame()
-        data['X'] = np.random.normal(size=n)
-        data['Z'] = np.random.normal(size=n)
-        data['Y'] = 1 + 2*data['X'] - 1*data['Z'] + np.random.normal(size=n)
-        data['C'] = 1
-        data['off'] = np.random.uniform(0.4, 0.6, size=n)
-        Xvals = np.asarray(data[['C', 'X', 'Z']])
-        yvals = np.asarray(data['Y'])
-
-        def psi_ridge(theta):
-            return ee_ridge_regression(theta, X=Xvals, y=yvals,
-                                       model='linear',
-                                       penalty=[0., 5., 2.],
-                                       offset=data['off'])
-
-        ridge = MEstimator(psi_ridge, init=[0., 0., 0.])
-        ridge.estimate(solver='lm')
-
-        def psi_elastic(theta):
-            return ee_elasticnet_regression(theta, X=Xvals, y=yvals,
-                                            model='linear',
-                                            penalty=[0., 5., 2.], ratio=0,
-                                            offset=data['off'])
-
-        elastic = MEstimator(psi_elastic, init=[0., 0., 0.])
-        elastic.estimate(solver='lm')
-
-        # Checking mean estimate
-        npt.assert_allclose(ridge.theta,
-                            elastic.theta,
-                            atol=1e-5)
-
-    def test_robust_linear(self):
-        n = 1000
-        data = pd.DataFrame()
-        data['X'] = np.random.normal(size=n)
-        data['Z'] = np.random.normal(size=n)
-        data['Y'] = 1 + 2*data['X'] - 1*data['Z'] + np.random.normal(size=n)
-        data['C'] = 1
-        Xvals = np.asarray(data[['C', 'X', 'Z']])
-        yvals = np.asarray(data['Y'])
-
-        def psi_linear(theta):
-            return ee_regression(theta, X=Xvals, y=yvals,
-                                 model='linear')
-
-        linear = MEstimator(psi_linear, init=[0., 0., 0.])
-        linear.estimate(solver='lm')
-
-        def psi_robust(theta):
-            return ee_robust_regression(theta, X=Xvals, y=yvals,
-                                        model='linear', k=20)
-
-        robust = MEstimator(psi_robust, init=[0., 0., 0.])
-        robust.estimate(solver='lm')
-
-        # Checking mean estimate
-        npt.assert_allclose(linear.theta,
-                            robust.theta,
-                            atol=1e-6)
-
-    def test_robust_linear_offset(self):
-        n = 1000
-        data = pd.DataFrame()
-        data['X'] = np.random.normal(size=n)
-        data['Z'] = np.random.normal(size=n)
-        data['Y'] = 1 + 2*data['X'] - 1*data['Z'] + np.random.normal(size=n)
-        data['C'] = 1
-        data['off'] = np.random.uniform(0.4, 0.6, size=n)
-        Xvals = np.asarray(data[['C', 'X', 'Z']])
-        yvals = np.asarray(data['Y'])
-
-        def psi_linear(theta):
-            return ee_regression(theta, X=Xvals, y=yvals,
-                                 model='linear', offset=data['off'])
-
-        linear = MEstimator(psi_linear, init=[0., 0., 0.])
-        linear.estimate(solver='lm')
-
-        def psi_robust(theta):
-            return ee_robust_regression(theta, X=Xvals, y=yvals,
-                                        model='linear', k=20, offset=data['off'])
-
-        robust = MEstimator(psi_robust, init=[0., 0., 0.])
-        robust.estimate(solver='lm')
-
-        # Checking mean estimate
-        npt.assert_allclose(linear.theta,
-                            robust.theta,
-                            atol=1e-6)
-
-
-class TestEstimatingEquationsSurvival:
-
-    @pytest.fixture
-    def surv_data(self):
-        np.random.seed(1123211)
-        n = 200
-        d = pd.DataFrame()
-        d['C'] = np.random.weibull(a=1, size=n)
-        d['C'] = np.where(d['C'] > 5, 5, d['C'])
-        d['T'] = 0.8 * np.random.weibull(a=0.75, size=n)
-        d['delta'] = np.where(d['T'] < d['C'], 1, 0)
-        d['t'] = np.where(d['delta'] == 1, d['T'], d['C'])
-        return np.asarray(d['t']), np.asarray(d['delta'])
-
-    @pytest.fixture
-    def data(self):
-        np.random.seed(131313131)
-        n = 200
-        d = pd.DataFrame()
-        d['X'] = np.random.binomial(n=1, p=0.5, size=n)
-        d['W'] = np.random.binomial(n=1, p=0.5, size=n)
-        d['T'] = (1 / 1.25 + 1 / np.exp(0.5) * d['X']) * np.random.weibull(a=0.75, size=n)
-        d['C'] = np.random.weibull(a=1, size=n)
-        d['C'] = np.where(d['C'] > 10, 10, d['C'])
-
-        d['delta'] = np.where(d['T'] < d['C'], 1, 0)
-        d['t'] = np.where(d['delta'] == 1, d['T'], d['C'])
-        d['weight'] = np.random.uniform(1, 5, size=n)
-        return d
-
-    def test_exponential_model(self, surv_data):
-        """Tests exponential model estimating equation to lifelines.
-        """
-        times, events = surv_data
-
-        def psi(theta):
-            return ee_exponential_model(theta=theta[0],
-                                        t=times, delta=events)
-
-        mestimator = MEstimator(psi, init=[1.])
-        mestimator.estimate(solver="lm")
-
-        exf = ExponentialFitter()
-        exf.fit(times, events)
-        results = np.asarray(exf.summary[['coef', 'se(coef)', 'coef lower 95%', 'coef upper 95%']])
-
-        # Checking mean estimate
-        npt.assert_allclose(1 / mestimator.theta[0],
-                            np.asarray(results[0, 0]),
-                            atol=1e-5)
-
-        # No robust variance for lifeline's ExponentialFitter, so not checking against
-        # Checking variance estimates
-        # npt.assert_allclose(np.sqrt(np.diag(mestimator.variance)),
-        #                     np.asarray(results[0, 1]),
-        #                     atol=1e-6)
-
-        # Checking confidence interval estimates
-        # npt.assert_allclose(mestimator.confidence_intervals(),
-        #                     np.asarray(results[0, 2:]),
-        #                     atol=1e-5)
-
-    def test_exponential_survival(self, surv_data):
-        """Tests exponential measures estimating equation to lifelines.
-        """
-        times, events = surv_data
-
-        def psi(theta):
-            ee_exp = ee_exponential_model(theta=theta[0],
-                                          t=times, delta=events)
-            ee_surv = ee_exponential_measure(theta[1:], scale=theta[0],
-                                             times=[0.5, 1, 2, 3], n=times.shape[0],
-                                             measure="survival")
-            return np.vstack((ee_exp, ee_surv))
-
-        mestimator = MEstimator(psi, init=[1., 0.5, 0.5, 0.5, 0.5])
-        mestimator.estimate(solver="lm")
-
-        exf = ExponentialFitter()
-        exf.fit(times, events)
-        results = np.asarray(exf.survival_function_at_times(times=[0.5, 1, 2, 3]))
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta[1:],
-                            results,
-                            atol=1e-5)
-
-    def test_exponential_risk(self, surv_data):
-        """Tests exponential measures estimating equation to lifelines.
-        """
-        times, events = surv_data
-
-        def psi(theta):
-            ee_exp = ee_exponential_model(theta=theta[0],
-                                          t=times, delta=events)
-            ee_surv = ee_exponential_measure(theta[1:], scale=theta[0],
-                                             times=[0.5, 1, 2, 3], n=times.shape[0],
-                                             measure="risk")
-            return np.vstack((ee_exp, ee_surv))
-
-        mestimator = MEstimator(psi, init=[1., 0.5, 0.5, 0.5, 0.5])
-        mestimator.estimate(solver="lm")
-
-        exf = ExponentialFitter()
-        exf.fit(times, events)
-        results = exf.cumulative_density_at_times(times=[0.5, 1, 2, 3])
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta[1:],
-                            results,
-                            atol=1e-5)
-
-    def test_exponential_hazard(self, surv_data):
-        """Tests exponential measures estimating equation to lifelines.
-        """
-        times, events = surv_data
-
-        def psi(theta):
-            ee_exp = ee_exponential_model(theta=theta[0],
-                                          t=times, delta=events)
-            ee_surv = ee_exponential_measure(theta[1:], scale=theta[0],
-                                             times=[0.5, 1, 2, 3], n=times.shape[0],
-                                             measure="hazard")
-            return np.vstack((ee_exp, ee_surv))
-
-        mestimator = MEstimator(psi, init=[1., 0.5, 0.5, 0.5, 0.5])
-        mestimator.estimate(solver="lm")
-
-        exf = ExponentialFitter()
-        exf.fit(times, events)
-        results = np.asarray(exf.summary['coef'])[0]
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta[1:],
-                            [1/results]*4,
-                            atol=1e-5)
-
-    def test_exponential_cumulative_hazard(self, surv_data):
-        """Tests exponential measures estimating equation to lifelines.
-        """
-        times, events = surv_data
-
-        def psi(theta):
-            ee_exp = ee_exponential_model(theta=theta[0],
-                                          t=times, delta=events)
-            ee_surv = ee_exponential_measure(theta[1:], scale=theta[0],
-                                             times=[0.5, 1, 2, 3], n=times.shape[0],
-                                             measure="cumulative_hazard")
-            return np.vstack((ee_exp, ee_surv))
-
-        mestimator = MEstimator(psi, init=[1., 0.5, 0.5, 0.5, 0.5])
-        mestimator.estimate(solver="lm")
-
-        exf = ExponentialFitter()
-        exf.fit(times, events)
-        results = exf.cumulative_hazard_at_times(times=[0.5, 1, 2, 3])
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta[1:],
-                            results,
-                            atol=1e-5)
-
-    def test_exponential_density(self, surv_data):
-        """Tests exponential measures estimating equation to lifelines.
-        """
-        times, events = surv_data
-
-        def psi(theta):
-            ee_exp = ee_exponential_model(theta=theta[0],
-                                          t=times, delta=events)
-            ee_surv = ee_exponential_measure(theta[1:], scale=theta[0],
-                                             times=[0.5, 1, 2, 3], n=times.shape[0],
-                                             measure="density")
-            return np.vstack((ee_exp, ee_surv))
-
-        mestimator = MEstimator(psi, init=[1., 0.5, 0.5, 0.5, 0.5])
-        mestimator.estimate(solver="lm")
-
-        # NOTICE: lifelines fails here (some problem with the derivative), so skipping comparison
-        #   the density measure is still covered by the Weibull density prediction (so not a testing coverage problem)
-        # exf = ExponentialFitter()
-        # exf.fit(times, events)
-        # results = exf.density_at_times(times=[0.5, 1, 2, 3])
-        #
-        # # Checking mean estimate
-        # npt.assert_allclose(mestimator.theta[1:],
-        #                     results,
-        #                     atol=1e-5)
-        pass
-
-    def test_weibull_model(self, surv_data):
-        """Tests Weibull model estimating equation to lifelines.
-        """
-        times, events = surv_data
-
-        def psi(theta):
-            return ee_weibull_model(theta=theta,
-                                    t=times, delta=events)
-
-        mestimator = MEstimator(psi, init=[1., 1.])
-        mestimator.estimate(solver="lm")
-
-        wbf = WeibullFitter()
-        wbf.fit(times, events)
-        results = np.asarray(wbf.summary[['coef', 'se(coef)', 'coef lower 95%', 'coef upper 95%']])
-
-        # Checking mean estimate
-        npt.assert_allclose([(1 / mestimator.theta[0])**(1/mestimator.theta[1]), mestimator.theta[1]],
-                            np.asarray(results[:, 0]),
-                            atol=1e-4)
-
-        # No robust variance for lifeline's WeibullFitter, so not checking against
-        # Checking variance estimates
-        # npt.assert_allclose(np.sqrt(np.diag(mestimator.variance)),
-        #                     np.asarray(results[0, 1]),
-        #                     atol=1e-6)
-
-        # Checking confidence interval estimates
-        # npt.assert_allclose(mestimator.confidence_intervals(),
-        #                     np.asarray(results[0, 2:]),
-        #                     atol=1e-5)
-
-    def test_weibull_survival(self, surv_data):
-        """Tests Weibull measures estimating equation to lifelines.
-        """
-        times, events = surv_data
-
-        def psi(theta):
-            ee_wbl = ee_weibull_model(theta=theta[0:2],
-                                      t=times, delta=events)
-            ee_surv = ee_weibull_measure(theta[2:], scale=theta[0], shape=theta[1],
-                                         times=[0.5, 1, 2, 3], n=times.shape[0],
-                                         measure="survival")
-            return np.vstack((ee_wbl, ee_surv))
-
-        mestimator = MEstimator(psi, init=[1., 1., 0.5, 0.5, 0.5, 0.5])
-        mestimator.estimate(solver="lm")
-
-        wbf = WeibullFitter()
-        wbf.fit(times, events)
-        results = np.asarray(wbf.survival_function_at_times(times=[0.5, 1, 2, 3]))
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta[2:],
-                            results,
-                            atol=1e-5)
-
-    def test_weibull_risk(self, surv_data):
-        """Tests Weibull measures estimating equation to lifelines.
-        """
-        times, events = surv_data
-
-        def psi(theta):
-            ee_wbl = ee_weibull_model(theta=theta[0:2],
-                                      t=times, delta=events)
-            ee_surv = ee_weibull_measure(theta[2:], scale=theta[0], shape=theta[1],
-                                         times=[0.5, 1, 2, 3], n=times.shape[0],
-                                         measure="risk")
-            return np.vstack((ee_wbl, ee_surv))
-
-        mestimator = MEstimator(psi, init=[1., 1., 0.5, 0.5, 0.5, 0.5])
-        mestimator.estimate(solver="lm")
-
-        wbf = WeibullFitter()
-        wbf.fit(times, events)
-        results = np.asarray(wbf.cumulative_density_at_times(times=[0.5, 1, 2, 3]))
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta[2:],
-                            results,
-                            atol=1e-5)
-
-    def test_weibull_hazard(self, surv_data):
-        """Tests Weibull measures estimating equation to lifelines.
-        """
-        times, events = surv_data
-
-        def psi(theta):
-            ee_wbl = ee_weibull_model(theta=theta[0:2],
-                                      t=times, delta=events)
-            ee_surv = ee_weibull_measure(theta[2:], scale=theta[0], shape=theta[1],
-                                         times=[0.5, 1, 2, 3], n=times.shape[0],
-                                         measure="hazard")
-            return np.vstack((ee_wbl, ee_surv))
-
-        mestimator = MEstimator(psi, init=[1., 1., 0.5, 0.5, 0.5, 0.5])
-        mestimator.estimate(solver="lm")
-
-        wbf = WeibullFitter()
-        wbf.fit(times, events)
-        results = np.asarray(wbf.hazard_at_times(times=[0.5, 1, 2, 3]))
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta[2:],
-                            results,
-                            atol=1e-4)
-
-    def test_weibull_cumulative_hazard(self, surv_data):
-        """Tests Weibull measures estimating equation to lifelines.
-        """
-        times, events = surv_data
-
-        def psi(theta):
-            ee_wbl = ee_weibull_model(theta=theta[0:2],
-                                      t=times, delta=events)
-            ee_surv = ee_weibull_measure(theta[2:], scale=theta[0], shape=theta[1],
-                                         times=[0.5, 1, 2, 3], n=times.shape[0],
-                                         measure="cumulative_hazard")
-            return np.vstack((ee_wbl, ee_surv))
-
-        mestimator = MEstimator(psi, init=[1., 1., 0.5, 0.5, 0.5, 0.5])
-        mestimator.estimate(solver="lm")
-
-        wbf = WeibullFitter()
-        wbf.fit(times, events)
-        results = np.asarray(wbf.cumulative_hazard_at_times(times=[0.5, 1, 2, 3]))
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta[2:],
-                            results,
-                            atol=1e-4)
-
-    def test_weibull_density(self, surv_data):
-        """Tests Weibull measures estimating equation to lifelines.
-        """
-        times, events = surv_data
-
-        def psi(theta):
-            ee_wbl = ee_weibull_model(theta=theta[0:2],
-                                      t=times, delta=events)
-            ee_surv = ee_weibull_measure(theta[2:], scale=theta[0], shape=theta[1],
-                                         times=[0.5, 1, 2, 3], n=times.shape[0],
-                                         measure="density")
-            return np.vstack((ee_wbl, ee_surv))
-
-        mestimator = MEstimator(psi, init=[1., 1., 0.5, 0.5, 0.5, 0.5])
-        mestimator.estimate(solver="lm")
-
-        wbf = WeibullFitter()
-        wbf.fit(times, events)
-        results = np.asarray(wbf.density_at_times(times=[0.5, 1, 2, 3]))
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta[2:],
-                            results,
-                            atol=1e-5)
-
-    def test_weibull_aft(self, data):
-        """Tests Weibull AFT estimating equation to lifelines.
-        """
-        def psi(theta):
-            return ee_aft_weibull(theta=theta,
-                                  t=data['t'], delta=data['delta'], X=data[['X', 'W']])
-
-        # M-estimator with built-in Weibull AFT
-        mestimator = MEstimator(psi, init=[-.5, 0.7, 0., 0.])
-        mestimator.estimate(solver="lm")
-
-        # Weibull AFT from lifelines for comparison
-        waft = WeibullAFTFitter()
-        waft.fit(data[['X', 'W', 't', 'delta']], 't', 'delta',
-                 ancillary=False, robust=True)
-        results = np.asarray(waft.summary[['coef', 'se(coef)', 'coef lower 95%', 'coef upper 95%']])
-        results = results[[2, 1, 0, 3], :]
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta,
-                            np.asarray(results[:, 0]),
-                            atol=1e-5)
-
-        # Checking variance estimates
-        npt.assert_allclose(np.sqrt(np.diag(mestimator.variance)),
-                            np.asarray(results[:, 1]),
-                            atol=1e-6)
-
-        # Checking confidence interval estimates
-        npt.assert_allclose(mestimator.confidence_intervals(),
-                            np.asarray(results[:, 2:]),
-                            atol=1e-5)
-
-    def test_weighted_weibull_aft(self, data):
-        """Tests weighted Weibull AFT estimating equation to lifelines.
-        """
-        def psi(theta):
-            return ee_aft_weibull(theta=theta, weights=data['weight'],
-                                  t=data['t'], delta=data['delta'], X=data[['X', 'W']])
-
-        # M-estimator with built-in Weibull AFT
-        mestimator = MEstimator(psi, init=[0., 0., 0., 0.])
-        mestimator.estimate(solver="lm")
-
-        # Weibull AFT from lifelines for comparison
-        waft = WeibullAFTFitter()
-        waft.fit(data[['X', 'W', 't', 'delta', 'weight']], 't', 'delta',
-                 weights_col='weight', ancillary=False, robust=True)
-        results = np.asarray(waft.summary[['coef', 'se(coef)', 'coef lower 95%', 'coef upper 95%']])
-        results = results[[2, 1, 0, 3], :]
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta,
-                            np.asarray(results[:, 0]),
-                            atol=1e-5)
-
-        # No variance check, since lifelines uses a different estimator
-
-    def test_weibull_aft_survival(self, data):
-        """Tests predicted survival at several time points for Weibull AFT estimating equation to lifelines.
-        """
-        # Times to evaluate and covariate pattern to examine
-        times_to_eval = [1, 1.25, 3, 5]
-        dta = data.copy()
-        dta['X'] = 1
-        dta['W'] = 1
-
-        def psi(theta):
-            aft = ee_aft_weibull(theta=theta[0:4], t=data['t'], delta=data['delta'], X=data[['X', 'W']])
-            pred_surv_t = ee_aft_weibull_measure(theta=theta[4:], X=dta[['X', 'W']],
-                                                 times=times_to_eval, measure='survival',
-                                                 mu=theta[0], beta=theta[1:3], sigma=theta[3])
-            return np.vstack((aft, pred_surv_t))
-
-        # M-estimator with built-in Weibull AFT
-        mestimator = MEstimator(psi, init=[-.5, 0.7, 0., -.2, ] + [0.5, ]*len(times_to_eval))
-        mestimator.estimate(solver="lm")
-
-        # Predictions from Weibull AFT from lifelines for comparison
-        waft = WeibullAFTFitter()
-        waft.fit(data[['X', 'W', 't', 'delta']], 't', 'delta', ancillary=False, robust=True)
-        preds = waft.predict_survival_function(dta.iloc[0], times=times_to_eval)
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta[4:],
-                            np.asarray(preds).T[0],
-                            atol=1e-5)
-
-    def test_weibull_aft_risk(self, data):
-        """Tests predicted risk at several time points for Weibull AFT estimating equation to lifelines.
-        """
-        # Times to evaluate and covariate pattern to examine
-        times_to_eval = [1, 1.25, 3, 5]
-        dta = data.copy()
-        dta['X'] = 1
-        dta['W'] = 1
-
-        def psi(theta):
-            aft = ee_aft_weibull(theta=theta[0:4], t=data['t'], delta=data['delta'], X=data[['X', 'W']])
-            pred_surv_t = ee_aft_weibull_measure(theta=theta[4:], X=dta[['X', 'W']],
-                                                 times=times_to_eval, measure='risk',
-                                                 mu=theta[0], beta=theta[1:3], sigma=theta[3])
-            return np.vstack((aft, pred_surv_t))
-
-        # M-estimator with built-in Weibull AFT
-        mestimator = MEstimator(psi, init=[-.5, 0.7, 0., -.2, ] + [0.5, ]*len(times_to_eval))
-        mestimator.estimate(solver="lm")
-
-        # Predictions from Weibull AFT from lifelines for comparison
-        waft = WeibullAFTFitter()
-        waft.fit(data[['X', 'W', 't', 'delta']], 't', 'delta', ancillary=False, robust=True)
-        preds = 1 - waft.predict_survival_function(dta.iloc[0], times=times_to_eval)
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta[4:],
-                            np.asarray(preds).T[0],
-                            atol=1e-5)
-
-    def test_weibull_aft_density(self, data):
-        """Tests predicted density at several time points for Weibull AFT estimating equation to lifelines.
-        """
-        # Times to evaluate and covariate pattern to examine
-        times_to_eval = [1, 1.25, 3, 5]
-        dta = data.copy()
-        dta['X'] = 1
-        dta['W'] = 1
-
-        def psi(theta):
-            aft = ee_aft_weibull(theta=theta[0:4], t=data['t'], delta=data['delta'], X=data[['X', 'W']])
-            pred_surv_t = ee_aft_weibull_measure(theta=theta[4:], X=dta[['X', 'W']],
-                                                 times=times_to_eval, measure='density',
-                                                 mu=theta[0], beta=theta[1:3], sigma=theta[3])
-            return np.vstack((aft, pred_surv_t))
-
-        # M-estimator with built-in Weibull AFT
-        mestimator = MEstimator(psi, init=[-.5, 0.7, 0., -.2, ] + [0.5, ]*len(times_to_eval))
-        mestimator.estimate(solver="lm")
-
-        # Predictions from Weibull AFT from lifelines for comparison
-        waft = WeibullAFTFitter()
-        waft.fit(data[['X', 'W', 't', 'delta']], 't', 'delta', ancillary=False, robust=True)
-        preds = (waft.predict_survival_function(dta.iloc[0], times=times_to_eval)
-                 * waft.predict_hazard(dta.iloc[0], times=times_to_eval))
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta[4:],
-                            np.asarray(preds).T[0],
-                            atol=1e-5)
-
-    def test_weibull_aft_hazard(self, data):
-        """Tests predicted hazard at several time points for Weibull AFT estimating equation to lifelines.
-        """
-        # Times to evaluate and covariate pattern to examine
-        times_to_eval = [1, 1.25, 3, 5]
-        dta = data.copy()
-        dta['X'] = 1
-        dta['W'] = 1
-
-        def psi(theta):
-            aft = ee_aft_weibull(theta=theta[0:4], t=data['t'], delta=data['delta'], X=data[['X', 'W']])
-            pred_surv_t = ee_aft_weibull_measure(theta=theta[4:], X=dta[['X', 'W']],
-                                                 times=times_to_eval, measure='hazard',
-                                                 mu=theta[0], beta=theta[1:3], sigma=theta[3])
-            return np.vstack((aft, pred_surv_t))
-
-        # M-estimator with built-in Weibull AFT
-        mestimator = MEstimator(psi, init=[-.5, 0.7, 0., -.2, ] + [0.5, ]*len(times_to_eval))
-        mestimator.estimate(solver="lm")
-
-        # Predictions from Weibull AFT from lifelines for comparison
-        waft = WeibullAFTFitter()
-        waft.fit(data[['X', 'W', 't', 'delta']], 't', 'delta', ancillary=False, robust=True)
-        preds = waft.predict_hazard(dta.iloc[0], times=times_to_eval)
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta[4:],
-                            np.asarray(preds).T[0],
-                            atol=1e-5)
-
-    def test_weibull_aft_cumulative_hazard(self, data):
-        """Tests predicted cumulative hazard at several time points for Weibull AFT estimating equation to lifelines.
-        """
-        # Times to evaluate and covariate pattern to examine
-        times_to_eval = [1, 1.25, 3, 5]
-        dta = data.copy()
-        dta['X'] = 1
-        dta['W'] = 1
-
-        def psi(theta):
-            aft = ee_aft_weibull(theta=theta[0:4], t=data['t'], delta=data['delta'], X=data[['X', 'W']])
-            pred_surv_t = ee_aft_weibull_measure(theta=theta[4:], X=dta[['X', 'W']],
-                                                 times=times_to_eval, measure='cumulative_hazard',
-                                                 mu=theta[0], beta=theta[1:3], sigma=theta[3])
-            return np.vstack((aft, pred_surv_t))
-
-        # M-estimator with built-in Weibull AFT
-        mestimator = MEstimator(psi, init=[-.5, 0.7, 0., -.2, ] + [0.5, ]*len(times_to_eval))
-        mestimator.estimate(solver="lm")
-
-        # Predictions from Weibull AFT from lifelines for comparison
-        waft = WeibullAFTFitter()
-        waft.fit(data[['X', 'W', 't', 'delta']], 't', 'delta', ancillary=False, robust=True)
-        preds = waft.predict_cumulative_hazard(dta.iloc[0], times=times_to_eval)
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta[4:],
-                            np.asarray(preds).T[0],
-                            atol=1e-4)
-
-
-class TestEstimatingEquationsDoseResponse:
-
-    def test_4pl(self):
-        """Test the 4 parameter log-logistic model using Inderjit et al. (2002)
-
-        Compares against R's drc library:
-
-        library(drc)
-        library(sandwich)
-        library(lmtest)
-
-        data(ryegrass)
-        rgll4 = drm(rootl ~ conc, data=ryegrass, fct=LL.4())
-        coeftest(rgll4, vcov=sandwich)
-        """
-        d = load_inderjit()
-        dose_data = d[:, 1]
-        resp_data = d[:, 0]
-
-        def psi(theta):
-            return ee_4p_logistic(theta=theta, X=dose_data, y=resp_data)
-
-        # Optimization procedure
-        mestimator = MEstimator(psi, init=[0, 2, 1, 10])
-        mestimator.estimate(solver='lm')
-
-        # R optimization from Ritz et al.
-        comparison_theta = np.asarray([0.48141, 3.05795, 2.98222, 7.79296])
-        comparison_var = np.asarray([0.12779, 0.26741, 0.47438, 0.15311])
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta,
-                            comparison_theta,
-                            atol=1e-5)
-
-        # Checking variance estimate
-        npt.assert_allclose(np.diag(mestimator.variance)**0.5,
-                            comparison_var,
-                            atol=1e-4)
-
-    def test_3pl(self):
-        """Test the 3 parameter log-logistic model using Inderjit et al. (2002)
-
-        Compares against R's drc library:
-
-        library(drc)
-        library(sandwich)
-        library(lmtest)
-
-        data(ryegrass)
-        rgll3 = drm(rootl ~ conc, data=ryegrass, fct=LL.3())
-        coeftest(rgll3, vcov=sandwich)
-        """
-        d = load_inderjit()
-        dose_data = d[:, 1]
-        resp_data = d[:, 0]
-
-        def psi(theta):
-            return ee_3p_logistic(theta=theta, X=dose_data, y=resp_data,
-                                  lower=0)
-
-        # Optimization procedure
-        mestimator = MEstimator(psi, init=[2, 1, 10])
-        mestimator.estimate(solver='lm')
-
-        # R optimization from Ritz et al.
-        comparison_theta = np.asarray([3.26336, 2.47033, 7.85543])
-        comparison_var = np.asarray([0.26572, 0.29238, 0.15397])
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta,
-                            comparison_theta,
-                            atol=1e-5)
-
-        # Checking variance estimate
-        npt.assert_allclose(np.diag(mestimator.variance)**0.5,
-                            comparison_var,
-                            atol=1e-5)
-
-    def test_2pl(self):
-        """Test the 2 parameter log-logistic model using Inderjit et al. (2002)
-
-        Compares against R's drc library:
-
-        library(drc)
-        library(sandwich)
-        library(lmtest)
-
-        data(ryegrass)
-        rgll2 = drm(rootl ~ conc, data=ryegrass, fct=LL.2(upper=8))
-        coeftest(rgll2, vcov=sandwich)
-        """
-        d = load_inderjit()
-        dose_data = d[:, 1]
-        resp_data = d[:, 0]
-
-        def psi(theta):
-            return ee_2p_logistic(theta=theta, X=dose_data, y=resp_data,
-                                  lower=0, upper=8)
-
-        # Optimization procedure
-        mestimator = MEstimator(psi, init=[2, 1])
-        mestimator.estimate(solver='lm')
-
-        # R optimization from Ritz et al.
-        comparison_theta = np.asarray([3.19946, 2.38220])
-        comparison_var = np.asarray([0.24290, 0.27937])
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta,
-                            comparison_theta,
-                            atol=1e-5)
-
-        # Checking variance estimate
-        npt.assert_allclose(np.diag(mestimator.variance)**0.5,
-                            comparison_var,
-                            atol=1e-5)
-
-    def test_3pl_ed_delta(self):
-        """Test the ED(alpha) calculation with the 3 parameter log-logistic model using Inderjit et al. (2002)
-
-        Compares against R's drc library:
-
-        library(drc)
-        library(sandwich)
-
-        data(ryegrass)
-        rgll3 = drm(rootl ~ conc, data=ryegrass, fct=LL.3())
-        ED(rgll3, c(5, 10, 50), interval='delta', vcov=sandwich)
-        """
-        d = load_inderjit()
-        dose_data = d[:, 1]
-        resp_data = d[:, 0]
-
-        def psi(theta):
-            lower_limit = 0
-            pl3 = ee_3p_logistic(theta=theta, X=dose_data, y=resp_data,
-                                 lower=lower_limit)
-            ed05 = ee_effective_dose_delta(theta[3], y=resp_data, delta=0.05,
-                                           steepness=theta[0], ed50=theta[1],
-                                           lower=lower_limit, upper=theta[2])
-            ed10 = ee_effective_dose_delta(theta[4], y=resp_data, delta=0.10,
-                                           steepness=theta[0], ed50=theta[1],
-                                           lower=lower_limit, upper=theta[2])
-            ed50 = ee_effective_dose_delta(theta[5], y=resp_data, delta=0.50,
-                                           steepness=theta[0], ed50=theta[1],
-                                           lower=lower_limit, upper=theta[2])
-            return np.vstack((pl3,
-                              ed05,
-                              ed10,
-                              ed50))
-
-        # Optimization procedure
-        mestimator = MEstimator(psi, init=[2, 1, 10, 1, 1, 2])
-        mestimator.estimate(solver='lm')
-
-        # R optimization from Ritz et al.
-        comparison_theta = np.asarray([0.99088, 1.34086, 3.26336])
-        comparison_var = np.asarray([0.12397, 0.13134, 0.26572])
-
-        # Checking mean estimate
-        npt.assert_allclose(mestimator.theta[-3:],
-                            comparison_theta,
-                            atol=1e-5)
-
-        # Checking variance estimate
-        npt.assert_allclose(np.diag(mestimator.variance)[-3:]**0.5,
-                            comparison_var,
-                            atol=1e-5)
-
-
-class TestEstimatingEquationsCausal:
-
-    @pytest.fixture
-    def causal_data(self):
-        np.random.seed(1205811)
-        n = 1000
-        df = pd.DataFrame()
-        # Covariates
-        df['W'] = np.random.binomial(1, p=0.5, size=n)
-        df['A'] = np.random.binomial(1, p=(0.25 + 0.5 * df['W']), size=n)
-        df['C'] = 1
-
-        # Potential outcomes
-        df['Ya0'] = np.random.binomial(1, p=(0.75 - 0.5 * df['W']), size=n)
-        df['Ya1'] = np.random.binomial(1, p=(0.75 - 0.5 * df['W'] - 0.1 * 1), size=n)
-
-        # Applying causal consistency
-        df['Y'] = (1 - df['A']) * df['Ya0'] + df['A'] * df['Ya1']
-        return df
-
-    @pytest.fixture
-    def causal_miss_data(self):
-        d = pd.DataFrame()
-        d['W'] = [1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
-                  1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
-                  1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3]
-        d['V'] = [1, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0,
-                  1, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0,
-                  1, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0]
-        d['A'] = [1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 1,
-                  1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 1,
-                  1, 1, 1, 1, 0, 1, 0, 1, 1, 1, 1, 0, 1, 0, 1]
-        d['Y'] = [3, 5, 1, 5, 2, 5, 2, 1, 4, 2, 3, 4, 2, 5, 5,
-                  3, 5, 1, 5, 2, 5, 2, 1, 4, 2, 3, 4, 2, 5, 5,
-                  ] + [np.nan, ]*15
-        d['I'] = 1
-        return d
-
-    def test_gformula(self, causal_data):
-        d1 = causal_data.copy()
-        d1['A'] = 1
-        d0 = causal_data.copy()
-        d0['A'] = 0
-
-        # M-estimation
-        def psi(theta):
-            return ee_gformula(theta,
-                               y=causal_data['Y'],
-                               X=causal_data[['C', 'A', 'W']],
-                               X1=d1[['C', 'A', 'W']],
-                               X0=d0[['C', 'A', 'W']])
-
-        mestimator = MEstimator(psi, init=[0., 0.5, 0.5, 0., 0., 0.])
-        mestimator.estimate(solver='lm')
-
-        # By-hand g-formula with statsmodels
-        glm = sm.GLM(causal_data['Y'], causal_data[['C', 'A', 'W']],
-                     family=sm.families.Binomial()).fit()
-        cd = causal_data[['C', 'A', 'W']].copy()
-        cd['A'] = 1
-        ya1 = glm.predict(cd)
-        cd['A'] = 0
-        ya0 = glm.predict(cd)
-
-        # Checking logistic coefficients (nuisance model estimates)
-        npt.assert_allclose(mestimator.theta[3:],
-                            np.asarray(glm.params),
-                            atol=1e-6)
-        # Checking mean estimates
-        npt.assert_allclose(mestimator.theta[0],
-                            np.mean(ya1) - np.mean(ya0),
-                            atol=1e-6)
-        npt.assert_allclose(mestimator.theta[1],
-                            np.mean(ya1),
-                            atol=1e-6)
-        npt.assert_allclose(mestimator.theta[2],
-                            np.mean(ya0),
-                            atol=1e-6)
-
-    def test_gcomp_bad_dimensions_error(self, causal_data):
-        d1 = causal_data.copy()
-        d1['A'] = 1
-        d0 = causal_data.copy()
-        d0['A'] = 0
-
-        # M-estimation
-        def psi(theta):
-            return ee_gformula(theta,
-                               y=causal_data['Y'],
-                               X=causal_data[['C', 'A', 'W']],
-                               X1=d1[['C', 'W']])
-
-        mestimator = MEstimator(psi, init=[0.5, 0., 0., 0.])
-        with pytest.raises(ValueError, match="The dimensions of X and X1"):
-            mestimator.estimate(solver='lm')
-
-        def psi(theta):
-            return ee_gformula(theta,
-                               y=causal_data['Y'],
-                               X=causal_data[['C', 'A', 'W']],
-                               X1=d1[['C', 'A', 'W']],
-                               X0=d0[['C', 'A']])
-
-        mestimator = MEstimator(psi, init=[0., 0.5, 0.5, 0., 0., 0.])
-        with pytest.raises(ValueError, match="The dimensions of X and X0"):
-            mestimator.estimate(solver='lm')
-
-    def test_ipw(self, causal_data):
-        # M-estimation
-        def psi(theta):
-            return ee_ipw(theta,
-                          y=causal_data['Y'],
-                          A=causal_data['A'],
-                          W=causal_data[['C', 'W']])
-
-        mestimator = MEstimator(psi, init=[0., 0.5, 0.5, 0., 0.])
-        mestimator.estimate(solver='lm')
-
-        # By-hand IPW estimator with statsmodels
-        glm = sm.GLM(causal_data['A'], causal_data[['C', 'W']],
-                     family=sm.families.Binomial()).fit()
-        pi = glm.predict()
-        ya1 = causal_data['A'] * causal_data['Y'] / pi
-        ya0 = (1-causal_data['A']) * causal_data['Y'] / (1-pi)
-
-        # Checking logistic coefficients (nuisance model estimates)
-        npt.assert_allclose(mestimator.theta[3:],
-                            np.asarray(glm.params),
-                            atol=1e-6)
-        # Checking mean estimates
-        npt.assert_allclose(mestimator.theta[0],
-                            np.mean(ya1) - np.mean(ya0),
-                            atol=1e-6)
-        npt.assert_allclose(mestimator.theta[1],
-                            np.mean(ya1),
-                            atol=1e-6)
-        npt.assert_allclose(mestimator.theta[2],
-                            np.mean(ya0),
-                            atol=1e-6)
-
-    def test_ipw_truncate(self, causal_data):
-        # M-estimation
-        def psi(theta):
-            return ee_ipw(theta,
-                          y=causal_data['Y'],
-                          A=causal_data['A'],
-                          W=causal_data[['C', 'W']],
-                          truncate=(0.1, 0.5))
-
-        mestimator = MEstimator(psi, init=[0., 0.5, 0.5, 0., 0.])
-        mestimator.estimate(solver='lm')
-
-        # By-hand IPW estimator with statsmodels
-        glm = sm.GLM(causal_data['A'], causal_data[['C', 'W']],
-                     family=sm.families.Binomial()).fit()
-        pi = glm.predict()
-        pi = np.clip(pi, 0.1, 0.5)
-        ya1 = causal_data['A'] * causal_data['Y'] / pi
-        ya0 = (1-causal_data['A']) * causal_data['Y'] / (1-pi)
-
-        # Checking logistic coefficients (nuisance model estimates)
-        npt.assert_allclose(mestimator.theta[3:],
-                            np.asarray(glm.params),
-                            atol=1e-6)
-        # Checking mean estimates
-        npt.assert_allclose(mestimator.theta[0],
-                            np.mean(ya1) - np.mean(ya0),
-                            atol=1e-6)
-        npt.assert_allclose(mestimator.theta[1],
-                            np.mean(ya1),
-                            atol=1e-6)
-        npt.assert_allclose(mestimator.theta[2],
-                            np.mean(ya0),
-                            atol=1e-6)
-
-    def test_ipw_truncate_error(self, causal_data):
-        # M-estimation
-        def psi(theta):
-            return ee_ipw(theta,
-                          y=causal_data['Y'],
-                          A=causal_data['A'],
-                          W=causal_data[['C', 'W']],
-                          truncate=(0.99, 0.01))
-
-        mestimator = MEstimator(psi, init=[0., 0.5, 0.5, 0., 0.])
-        with pytest.raises(ValueError, match="truncate values"):
-            mestimator.estimate()
-
-    def test_ipw_weights(self, causal_miss_data):
-        # Setting up data
-        d = causal_miss_data
-        W = d[['I', 'V', 'W']]
-        X = d[['I', 'A']]
-        a = d['A']
-        y = d['Y']
-        r = np.where(d['Y'].isna(), 0, 1)
-
-        # M-estimation
-        def psi(theta):
-            # Separating parameters out
-            alpha = theta[:3 + W.shape[1]]  # MSM & PS
-            gamma = theta[3 + W.shape[1]:]  # Missing score
-
-            # Estimating equation for IPMW
-            ee_ms = ee_regression(theta=gamma,
-                                  X=X, y=r,
-                                  model='logistic')
-            pi_m = inverse_logit(np.dot(X, gamma))
-            ipmw = r / pi_m
-
-            # Estimating equations for MSM and PS
-            ee_msm = ee_ipw(theta=alpha, y=y, A=a, W=W,
-                            weights=ipmw)
-            ee_msm = np.nan_to_num(ee_msm, copy=False, nan=0.)
-            return np.vstack([ee_msm, ee_ms])
-
-        init_vals = [0., 3., 3., ] + [0., 0., 0.] + [0., 0.]
-        mestr = MEstimator(psi, init=init_vals)
-        mestr.estimate(solver='lm')
-
-        # By-hand IPW estimator with statsmodels
-        glm_ps = sm.GLM(a, W, family=sm.families.Binomial()).fit()
-        pi_a = glm_ps.predict()
-        glm_ms = sm.GLM(r, X, family=sm.families.Binomial()).fit()
-        pi_m = glm_ms.predict()
-        ipmw = r / pi_m
-
-        ya1 = d['A'] * d['Y'] / pi_a * ipmw
-        ya0 = (1-d['A']) * d['Y'] / (1-pi_a) * ipmw
-
-        # Checking logistic coefficients (nuisance model estimates)
-        npt.assert_allclose(mestr.theta[3:6],
-                            np.asarray(glm_ps.params),
-                            atol=1e-6)
-        npt.assert_allclose(mestr.theta[6:],
-                            np.asarray(glm_ms.params),
-                            atol=1e-6)
-        # Checking mean estimates
-        npt.assert_allclose(mestr.theta[0],
-                            np.mean(ya1) - np.mean(ya0),
-                            atol=1e-6)
-        npt.assert_allclose(mestr.theta[1],
-                            np.mean(ya1),
-                            atol=1e-6)
-        npt.assert_allclose(mestr.theta[2],
-                            np.mean(ya0),
-                            atol=1e-6)
-
-    def test_ipw_msm(self, causal_data):
-        # M-estimation
-        def psi(theta):
-            return ee_ipw_msm(theta,
-                              y=causal_data['Y'],
-                              A=causal_data['A'],
-                              W=causal_data[['C', 'W']],
-                              V=causal_data[['C', 'A']],
-                              link='logit', distribution='binomial')
-
-        mestimator = MEstimator(psi, init=[0., 0., 0., 0.])
-        mestimator.estimate(solver='lm')
-
-        # By-hand IPW estimator with statsmodels
-        glm = sm.GLM(causal_data['A'], causal_data[['C', 'W']],
-                     family=sm.families.Binomial()).fit()
-        pi = glm.predict()
-        ipw = np.where(causal_data['A'] == 1, 1/pi, 1/(1-pi))
-        msm = sm.GEE(causal_data['Y'], causal_data[['C', 'A']],
-                     family=sm.families.Binomial(), weights=ipw,
-                     groups=causal_data.index).fit()
-
-        # Checking logistic coefficients (nuisance model estimates)
-        npt.assert_allclose(mestimator.theta[2:],
-                            np.asarray(glm.params),
-                            atol=1e-6)
-        # Checking mean estimates
-        npt.assert_allclose(mestimator.theta[0:2],
-                            msm.params,
-                            atol=1e-6)
-
-    def test_ipw_msm_truncate(self, causal_data):
-        # M-estimation
-        def psi(theta):
-            return ee_ipw_msm(theta,
-                              y=causal_data['Y'], A=causal_data['A'],
-                              W=causal_data[['C', 'W']], V=causal_data[['C', 'A']],
-                              link='logit', distribution='binomial', truncate=(0.1, 0.5))
-
-        mestimator = MEstimator(psi, init=[0., 0., 0., 0.])
-        mestimator.estimate(solver='lm')
-
-        # By-hand IPW estimator with statsmodels
-        glm = sm.GLM(causal_data['A'], causal_data[['C', 'W']],
-                     family=sm.families.Binomial()).fit()
-        pi = glm.predict()
-        pi = np.clip(pi, 0.1, 0.5)
-        ipw = np.where(causal_data['A'] == 1, 1/pi, 1/(1-pi))
-        msm = sm.GEE(causal_data['Y'], causal_data[['C', 'A']],
-                     family=sm.families.Binomial(), weights=ipw,
-                     groups=causal_data.index).fit()
-
-        # Checking logistic coefficients (nuisance model estimates)
-        npt.assert_allclose(mestimator.theta[2:],
-                            np.asarray(glm.params),
-                            atol=1e-6)
-        # Checking mean estimates
-        npt.assert_allclose(mestimator.theta[0:2],
-                            msm.params,
-                            atol=1e-6)
-
-    def test_ipw_msm_weights(self, causal_miss_data):
-        # Setting up data
-        d = causal_miss_data.copy()
-        W = d[['I', 'V', 'W']]
-        X = d[['I', 'A']]
-        msm = d[['I', 'A']]
-        a = d['A']
-        y = d['Y']
-        r = np.where(d['Y'].isna(), 0, 1)
-
-        # M-estimation
-        def psi(theta):
-            # Separating parameters out
-            alpha = theta[:2 + W.shape[1]]  # MSM & PS
-            gamma = theta[2 + W.shape[1]:]  # Missing score
-
-            # Estimating equation for IPMW
-            ee_ms = ee_regression(theta=gamma,
-                                  X=X, y=r,
-                                  model='logistic')
-            pi_m = inverse_logit(np.dot(X, gamma))
-            ipmw = r / pi_m
-
-            # Estimating equations for MSM and PS
-            ee_msm = ee_ipw_msm(alpha,
-                                y=y, A=a, W=W, V=msm,
-                                link='log', distribution='poisson',
-                                weights=ipmw)
-            ee_msm = np.nan_to_num(ee_msm, copy=False, nan=0.)
-            return np.vstack([ee_msm, ee_ms])
-
-        init_vals = [0., 0., ] + [0., 0., 0.] + [0., 0.]
-        mestr = MEstimator(psi, init=init_vals)
-        mestr.estimate(solver='lm', maxiter=5000)
-
-        # By-hand IPW estimator with statsmodels
-        glm_ps = sm.GLM(a, W, family=sm.families.Binomial()).fit()
-        pi_a = glm_ps.predict()
-        iptw = np.where(a == 1, 1/pi_a, 1/(1-pi_a))
-        glm_ms = sm.GLM(r, X, family=sm.families.Binomial()).fit()
-        pi_m = glm_ms.predict()
-        ipmw = r / pi_m
-        d['ipw'] = ipmw*iptw
-
-        dcc = d.dropna()
-        msm = sm.GEE(dcc['Y'], dcc[['I', 'A']], weights=dcc['ipw'],
-                     family=sm.families.Poisson(), groups=dcc.index).fit()
-
-        # Checking logistic coefficients (nuisance model estimates)
-        npt.assert_allclose(mestr.theta[2:5],
-                            np.asarray(glm_ps.params),
-                            atol=1e-6)
-        npt.assert_allclose(mestr.theta[5:],
-                            np.asarray(glm_ms.params),
-                            atol=1e-6)
-        # Checking mean estimates
-        npt.assert_allclose(mestr.theta[0:2],
-                            msm.params,
-                            atol=1e-6)
-
-    def test_aipw(self, causal_data):
-        d1 = causal_data.copy()
-        d1['A'] = 1
-        d0 = causal_data.copy()
-        d0['A'] = 0
-
-        # M-estimation
-        def psi_builtin_regression(theta):
-            return ee_aipw(theta,
-                           y=causal_data['Y'],
-                           A=causal_data['A'],
-                           W=causal_data[['C', 'W']],
-                           X=causal_data[['C', 'A', 'W']],
-                           X1=d1[['C', 'A', 'W']],
-                           X0=d0[['C', 'A', 'W']])
-
-        mestimator = MEstimator(psi_builtin_regression, init=[0., 0.5, 0.5,   # Parameters of interest
-                                                              0., 0., 0.,     # Outcome nuisance model
-                                                              0., 0.])        # Treatment nuisance model
-        mestimator.estimate(solver='lm', tolerance=1e-12)
-
-        # By-hand IPW estimator with statsmodels
-        pi_m = sm.GLM(causal_data['A'], causal_data[['C', 'W']],
-                      family=sm.families.Binomial()).fit()
-        y_m = sm.GLM(causal_data['Y'], causal_data[['C', 'A', 'W']],
-                     family=sm.families.Binomial()).fit()
-        # Predicting coefficients
-        pi = pi_m.predict()
-        cd = causal_data[['C', 'A', 'W']].copy()
-        cd['A'] = 1
-        ya1 = y_m.predict(cd)
-        cd['A'] = 0
-        ya0 = y_m.predict(cd)
-        # AIPW estimator
-        ya1_star = causal_data['Y'] * causal_data['A'] / pi - ya1 * (causal_data['A'] - pi) / pi
-        ya0_star = causal_data['Y'] * (1-causal_data['A']) / (1-pi) - ya0 * (pi - causal_data['A']) / (1-pi)
-        # AIPW variance estimator!
-        var_ate = np.nanvar((ya1_star - ya0_star) - np.mean(ya1_star - ya0_star), ddof=1) / causal_data.shape[0]
-        var_r1 = np.nanvar(ya1_star - np.mean(ya1_star), ddof=1) / causal_data.shape[0]
-        var_r0 = np.nanvar(ya0_star - np.mean(ya0_star), ddof=1) / causal_data.shape[0]
-
-        # Checking logistic coefficients (nuisance model estimates)
-        npt.assert_allclose(mestimator.theta[3:5],
-                            np.asarray(pi_m.params),
-                            atol=1e-6)
-        npt.assert_allclose(mestimator.theta[5:],
-                            np.asarray(y_m.params),
-                            atol=1e-6)
-
-        # Checking mean estimates
-        npt.assert_allclose(mestimator.theta[0],
-                            np.mean(ya1_star) - np.mean(ya0_star),
-                            atol=1e-6)
-        npt.assert_allclose(mestimator.theta[1],
-                            np.mean(ya1_star),
-                            atol=1e-6)
-        npt.assert_allclose(mestimator.theta[2],
-                            np.mean(ya0_star),
-                            atol=1e-6)
-
-        # Checking variance estimates
-        npt.assert_allclose(mestimator.variance[0, 0],
-                            var_ate,
-                            atol=1e-6)
-        npt.assert_allclose(mestimator.variance[1, 1],
-                            var_r1,
-                            atol=1e-6)
-        npt.assert_allclose(mestimator.variance[2, 2],
-                            var_r0,
-                            atol=1e-6)
-
-    def test_gestimaton_linear(self):
-        # Generating a simple test data set to compare with
-        d = pd.DataFrame()
-        d['W'] = [1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
-                  1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
-                  1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3]
-        d['V'] = [1, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0,
-                  1, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0,
-                  1, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0]
-        d['A'] = [1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 1,
-                  1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 1,
-                  1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 1]
-        d['Y'] = [3, 5, 1, 5, 2, 5, 2, 1, 4, 2, 3, 4, 2, 5, 5,
-                  3, 5, 1, 5, 2, 5, 2, 1, 4, 2, 3, 4, 2, 5, 5,
-                  3, 5, 1, 5, 2, 5, 2, 1, 4, 2, 3, 4, 2, 5, 5]
-        d['I'] = 1
-
-        # M-estimator
-        def psi(theta):
-            return ee_gestimation_snmm(theta=theta,
-                                       y=d['Y'], A=d['A'],
-                                       W=d[['I', 'V', 'W']],
-                                       V=d[['I', 'V']],
-                                       model='linear')
-
-        mestr = MEstimator(psi, init=[0., ] * 5)
-        mestr.estimate(solver='lm')
-
-        # Previously solved SNM using zEpid
-        snm_params = [0.499264398938, -0.400700107829]
-
-        # Previously solved variance
-        snm_var = [[0.5309405889911, -0.5497523512113, -0.08318452996742, 0.02706302088976,  0.02666983404626],
-                   [-0.5497523512113,  0.9114476962859,  0.20188395780370, -8.543908429690e-04, -0.08753380928183],
-                   [-0.08318452996742, 0.2018839578037,  0.84188596592780, -0.2000575145713, -0.2959629818969],
-                   [0.02706302088976, -8.543908429690e-04, -0.2000575145713, 0.4010462043495, -0.03045175600959],
-                   [0.02666983404626, -0.08753380928183, -0.2959629818969, -0.03045175600959,  0.1505645314902]]
-
-        # Checking SNM parameters
-        npt.assert_allclose(mestr.theta[0:2],
-                            snm_params,
-                            atol=1e-7)
-
-        # Checking variance
-        npt.assert_allclose(mestr.variance,
-                            snm_var,
-                            atol=1e-4)
-
-    def test_gestimation_linear_weighted(self, causal_miss_data):
-        # Setting up data for estimation equation
-        d = causal_miss_data
-        W = d[['I', 'V', 'W']]
-        X = d[['I', 'A']]
-        snm = d[['I', 'V']]
-        a = d['A']
-        y = d['Y']
-        r = np.where(d['Y'].isna(), 0, 1)
-
-        # M-estimator
-        def psi(theta):
-            # Dividing parameters into corresponding estimation equations
-            alpha = theta[:snm.shape[1] + W.shape[1]]  # SNM and PS
-            gamma = theta[snm.shape[1] + W.shape[1]:]  # Missing scores
-
-            # Estimating equation for IPMW
-            ee_ms = ee_regression(theta=gamma,  # Missing score
-                                  X=X, y=r,  # ... observed data
-                                  model='logistic')  # ... logit model
-            pi_m = inverse_logit(np.dot(X, gamma))  # Predicted prob
-            ipmw = r / pi_m  # Missing weights
-
-            # Estimating equations for PS
-            ee_snm = ee_gestimation_snmm(theta=alpha,
-                                         y=y, A=a,
-                                         W=W, V=snm,
-                                         model='linear',
-                                         weights=ipmw)
-            # Setting rows with missing Y's as zero (no contribution)
-            ee_snm = np.nan_to_num(ee_snm, copy=False, nan=0.)
-
-            return np.vstack([ee_snm, ee_ms])
-
-        init_values = [0., 0.] + [0., ]*3 + [0., ]*2
-        mestr = MEstimator(psi, init=init_values)
-        mestr.estimate(solver='lm')
-
-        # Comparison using zEpid
-        # from zepid.causal.snm import GEstimationSNM
-        # snm = GEstimationSNM(d, exposure='A', outcome='Y')
-        # snm.exposure_model("V + W")
-        # snm.missing_model("A", stabilized=False)
-        # snm.structural_nested_model("A + A:V")
-        # snm.fit()
-        snm_params = [0.478757884654, -0.366956950389,
-                      2.406293643425, -0.750506882472, -0.816256976888,
-                      1.252762968495, -0.878069519054]
-
-        # Previously solved variance
-        snm_var = [[0.833658518465, -0.863011204849],
-                   [-0.863011204849, 1.397401367295]]
-
-        # Checking SNM parameters
-        npt.assert_allclose(mestr.theta,
-                            snm_params,
-                            atol=1e-7)
-
-        # Checking variance
-        npt.assert_allclose(mestr.variance[:2, :2],
-                            snm_var,
-                            atol=1e-4)
-
-    def test_robins_sensitivity_mean(self):
-        d = pd.DataFrame()
-        d['I'] = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
-        d['X'] = [0, 1, 2, 0, 1, 2, 0, 1, 2, 0]
-        d['Y'] = [7, 2, 5, np.nan, 1, 4, 8, np.nan, 1, np.nan]
-        d['delta'] = np.where(d['Y'].isna(), 0, 1)
-
-        def q_function(y_vals, alpha):
-            y_no_miss = np.where(np.isnan(y_vals), 0, y_vals)
-            return alpha * y_no_miss
-
-        ####
-        # Checking with alpha=0.5
-        def psi(theta):
-            return ee_mean_sensitivity_analysis(theta=theta,
-                                                y=d['Y'], delta=d['delta'], X=d[['I', 'X']],
-                                                q_eval=q_function(d['Y'], alpha=0.5),
-                                                H_function=inverse_logit)
-
-        mestr = MEstimator(psi, init=[0., 0., 0.])
-        mestr.estimate(solver='lm')
-
-        # Checking point estimates
-        npt.assert_allclose(mestr.theta,
-                            [4.42581577, -3.72866034, 3.74204493],
-                            atol=1e-6)
-
-        # Checking variance estimates
-        var_closed = [[0.77498021, 0.00455995, -0.04176666],
-                      [0.00455995, 1.0032351, -0.94005101],
-                      [-0.04176666, -0.94005101, 2.26235294]]
-        npt.assert_allclose(mestr.variance,
-                            var_closed,
-                            atol=1e-6)
-
-        ####
-        # Checking with alpha=-0.5
-        def psi(theta):
-            return ee_mean_sensitivity_analysis(theta=theta,
-                                                y=d['Y'], delta=d['delta'], X=d[['I', 'X']],
-                                                q_eval=q_function(d['Y'], alpha=-0.5),
-                                                H_function=inverse_logit)
-
-        mestr = MEstimator(psi, init=[0., 0., 0.])
-        mestr.estimate(solver='lm')
-
-        # Checking point estimates
-        npt.assert_allclose(mestr.theta,
-                            [4.876513, 3.579188, 0.140411],
-                            atol=1e-6)
-
-        # Checking variance estimates
-        var_closed = [[0.75110762, 0.05973157, -0.11159881],
-                      [0.05973157, 0.88333613, -0.36712181],
-                      [-0.11159881, -0.36712181, 0.52362185]]
-        npt.assert_allclose(mestr.variance,
-                            var_closed,
-                            atol=1e-6)
-
-        ####
-        # Checking complete-case analysis
-        def psi(theta):
-            return ee_mean_sensitivity_analysis(theta=theta,
-                                                y=d['Y'], delta=d['delta'], X=d[['I', ]],
-                                                q_eval=q_function(d['Y'], alpha=0.),
-                                                H_function=inverse_logit)
-
-        mestr = MEstimator(psi, init=[0., 0.])
-        mestr.estimate(solver='lm')
-
-        # Checking point estimates
-        npt.assert_allclose(mestr.theta[0],
-                            np.nanmean(d['Y']),
-                            atol=1e-6)
diff --git a/tests/test_utilities.py b/tests/test_utilities.py
index a295582..42fc998 100644
--- a/tests/test_utilities.py
+++ b/tests/test_utilities.py
@@ -1,3 +1,7 @@
+####################################################################################################################
+# Tests for provided user-accessible utility functions
+####################################################################################################################
+
 import pytest
 import numpy as np
 import scipy as sp
@@ -294,33 +298,62 @@ def psi(theta):
         npt.assert_allclose(returned[:, 2], np.log(expected[:, 2]), atol=1e-6)
         npt.assert_allclose(returned[:, 3], np.log(expected[:, 3]), atol=1e-6)
 
-    def test_spline1(self):
+    def test_spline_unnormed(self):
         vars = [1, 5, 10, 15, 20]
 
         # Spline setup 1
         expected = np.array([[0.0, 0.0, 0.0, 0.0, 4.0], ]).T
-        returned = spline(variable=vars, knots=[16, ], power=1, restricted=False)
+        returned = spline(variable=vars, knots=[16, ], power=1, restricted=False, normalized=False)
         npt.assert_allclose(returned, expected)
 
         # Spline setup 2
         expected = np.array([[0.0, 0.0, 0.0, 0.0, 16.0], ]).T
-        returned = spline(variable=vars, knots=[16, ], power=2, restricted=False)
+        returned = spline(variable=vars, knots=[16, ], power=2, restricted=False, normalized=False)
         npt.assert_allclose(returned, expected)
 
         # Spline setup 3
         expected = np.array([[0.0, 0.0, 0.0, 5.0**1.5, 10.0**1.5],
                              [0.0, 0.0, 0.0, 0.0, 4.0**1.5]]).T
-        returned = spline(variable=vars, knots=[10, 16], power=1.5, restricted=False)
+        returned = spline(variable=vars, knots=[10, 16], power=1.5, restricted=False, normalized=False)
         npt.assert_allclose(returned, expected)
 
         # Spline setup 4
         expected = np.array([[0.0, 0.0, 0.0, 5.0, 6.0], ]).T
-        returned = spline(variable=vars, knots=[10, 16], power=1, restricted=True)
+        returned = spline(variable=vars, knots=[10, 16], power=1, restricted=True, normalized=False)
         npt.assert_allclose(returned, expected)
 
         # Spline setup 5
         expected = np.array([[0.0, 0.0, 5.0**2, 10.0**2, 15.0**2 - 4.0**2], ]).T
-        returned = spline(variable=vars, knots=[5, 16], power=2, restricted=True)
+        returned = spline(variable=vars, knots=[5, 16], power=2, restricted=True, normalized=False)
+        npt.assert_allclose(returned, expected)
+
+    def test_spline_normed(self):
+        vars = [1, 5, 10, 15, 20]
+
+        # Spline setup 1
+        expected = np.array([[0.0, 0.0, 0.0, 0.0, 4.0], ]).T / 16
+        returned = spline(variable=vars, knots=[16, ], power=1, restricted=False, normalized=True)
+        npt.assert_allclose(returned, expected)
+
+        # Spline setup 2
+        expected = np.array([[0.0, 0.0, 0.0, 0.0, 16.0], ]).T / 16**2
+        returned = spline(variable=vars, knots=[16, ], power=2, restricted=False, normalized=True)
+        npt.assert_allclose(returned, expected)
+
+        # Spline setup 3
+        expected = np.array([[0.0, 0.0, 0.0, 5.0**1.5, 10.0**1.5],
+                             [0.0, 0.0, 0.0, 0.0, 4.0**1.5]]).T / (16 - 10)**1.5
+        returned = spline(variable=vars, knots=[10, 16], power=1.5, restricted=False, normalized=True)
+        npt.assert_allclose(returned, expected)
+
+        # Spline setup 4
+        expected = np.array([[0.0, 0.0, 0.0, 5.0, 6.0], ]).T / (16 - 10)
+        returned = spline(variable=vars, knots=[10, 16], power=1, restricted=True, normalized=True)
+        npt.assert_allclose(returned, expected)
+
+        # Spline setup 5
+        expected = np.array([[0.0, 0.0, 5.0**2, 10.0**2, 15.0**2 - 4.0**2], ]).T / (16 - 5)**2
+        returned = spline(variable=vars, knots=[5, 16], power=2, restricted=True, normalized=True)
         npt.assert_allclose(returned, expected)
 
     def test_adm_error_noknots(self, design_matrix):
@@ -383,7 +416,7 @@ def test_adm_defaults(self, design_matrix):
 
     def test_adm_v1(self, design_matrix):
         specs = [None,
-                 {"knots": [16, ], "power": 1, "natural": False},
+                 {"knots": [16, ], "power": 1, "natural": False, "normalized": False},
                  None]
         expected = np.array([[1, 1, 1, 1, 1],
                              [1, 5, 10, 15, 20],
@@ -396,8 +429,8 @@ def test_adm_v1(self, design_matrix):
 
     def test_adm_v2(self, design_matrix):
         specs = [None,
-                 {"knots": [16, ], "power": 1, "natural": False},
-                 {"knots": [2, 4], "power": 2, "natural": True}]
+                 {"knots": [16, ], "power": 1, "natural": False, "normalized": False},
+                 {"knots": [2, 4], "power": 2, "natural": True, "normalized": False}]
         expected = np.array([[1, 1, 1, 1, 1],
                              [1, 5, 10, 15, 20],
                              [0, 0, 0, 0, 4],
@@ -411,8 +444,8 @@ def test_adm_v2(self, design_matrix):
     def test_adm_v3(self, design_matrix):
         # Testing non-ascending order correction for knots
         specs = [None,
-                 {"knots": [16, ], "power": 1, "natural": False},
-                 {"knots": [4, 2], "power": 2, "natural": True}]
+                 {"knots": [16, ], "power": 1, "natural": False, "normalized": False},
+                 {"knots": [4, 2], "power": 2, "natural": True, "normalized": False}]
         expected = np.array([[1, 1, 1, 1, 1],
                              [1, 5, 10, 15, 20],
                              [0, 0, 0, 0, 4],
@@ -426,8 +459,8 @@ def test_adm_v3(self, design_matrix):
     def test_adm_v4(self, design_matrix):
         # Testing number of generated rows
         specs = [None,
-                 {"knots": [16, ], "power": 2, "natural": False},
-                 {"knots": [2, 4], "power": 1, "natural": False}]
+                 {"knots": [16, ], "power": 2, "natural": False, "normalized": False},
+                 {"knots": [2, 4], "power": 1, "natural": False, "normalized": False}]
         expected = np.array([[1, 1, 1, 1, 1],
                              [1, 5, 10, 15, 20],
                              [0, 0, 0, 0, 4**2],
@@ -439,6 +472,22 @@ def test_adm_v4(self, design_matrix):
                                           return_penalty=False)
         npt.assert_allclose(returned, expected)
 
+    def test_adm_v5(self, design_matrix):
+        # Testing number of generated rows
+        specs = [None,
+                 {"knots": [16, ], "power": 2, "natural": False, "normalized": True},
+                 {"knots": [2, 4], "power": 1, "natural": False, "normalized": True}]
+        expected = np.array([[1, 1, 1, 1, 1],
+                             [1, 5, 10, 15, 20],
+                             [0, 0, 0/16**2, 0, 4**2/16**2],
+                             [1, 2, 3, 4, 5],
+                             [0, 0, 1/(4-2), 2/(4-2), 3/(4-2)],
+                             [0, 0, 0, 0, 1/(4-2)]]).T
+        returned = additive_design_matrix(X=design_matrix,
+                                          specifications=specs,
+                                          return_penalty=False)
+        npt.assert_allclose(returned, expected)
+
     def test_adm_penalty(self, design_matrix):
         specs = [None,
                  {"knots": [16, ], "power": 1, "natural": False, "penalty": 3},