Ross et al. (2024): Introduction to M-estimation

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.

Setup

[1]:
import numpy as np
import pandas as pd

import delicatessen
from delicatessen import MEstimator
from delicatessen.estimating_equations import ee_regression, ee_rogan_gladen
from delicatessen.utilities import inverse_logit

print("Versions")
print("NumPy:        ", np.__version__)
print("pandas:       ", pd.__version__)
print("Delicatessen: ", delicatessen.__version__)
Versions
NumPy:         1.25.2
pandas:        1.4.1
Delicatessen:  2.1

The following data is used for the first and second cases.

[2]:
# From Table 1
d = pd.DataFrame()
d['X'] = [0, 0, 0, 0, 1, 1, 1, 1]            # X values
d['W'] = [0, 0, 1, 1, 0, 0, 1, 1]            # W values
d['Y'] = [0, 1, 0, 1, 0, 1, 0, 1]            # Y values
d['n'] = [496, 74, 113, 25, 85, 15, 15, 3]   # Counts
d['intercept'] = 1                           # Intercept term (always 1)

# Expanding rows by n
d = pd.DataFrame(np.repeat(d.values,         # Converting tabled data
                           d['n'], axis=0),  # ... by replicating counts
                 columns=d.columns)          # ... into rows for each X,W,Y
d = d[['intercept', 'X', 'W', 'Y']].copy()   # Dropping extra rows

Example 1: Logistic Regression

For the first example, we fit a logistic regression model for the variable \(Y\) given \(X,W\).

[3]:
# Extracting arrays for easier coding later on
X = np.asarray(d[['intercept', 'X', 'W']])   # Design matrix for regression
y = np.asarray(d['Y'])                       # Outcome in regression

For estimation, we can use the built-in function for regression

[4]:
def psi(theta):
    return ee_regression(theta=theta,
                         y=y, X=X,
                         model='logistic')
[5]:
estr = MEstimator(psi, init=[0, 0, 0,])
estr.estimate()
[6]:
# Formatting results into a nice table
result = pd.DataFrame()
result['Param'] = ['beta_0', 'beta_1', 'beta_2']
result['Coef'] = estr.theta
ci = estr.confidence_intervals()
result['LCL'] = ci[:, 0]
result['UCL'] = ci[:, 1]
result.round(2)
[6]:
Param Coef LCL UCL
0 beta_0 -1.89 -2.13 -1.66
1 beta_1 0.12 -0.43 0.67
2 beta_2 0.36 -0.11 0.83

These results match those reported in the paper. They also match the results if you were to fit this using statsmodels GLM instead.

Example 2: Estimating the marginal risk difference

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.

First, let’s apply g-computation

[7]:
# Copies of data with policies applied
d1 = d.copy()
d1['X'] = 1
X1 = np.asarray(d1[['intercept', 'X', 'W']])
d0 = d.copy()
d0['X'] = 0
X0 = np.asarray(d0[['intercept', 'X', 'W']])
[8]:
def psi(theta):
    # Dividing parameters into corresponding parts and labels from slides
    beta = theta[0:3]              # Logistic model coefficients
    mu0, mu1 = theta[3], theta[4]  # Causal risks
    delta1 = theta[5]              # Causal contrast

    # Logistic regression model for outcome
    ee_logit = ee_regression(theta=beta,
                             y=y, X=X,
                             model='logistic')

    # Transforming logistic model coefficients into causal parameters
    y0_hat = inverse_logit(np.dot(X0, beta))  # Prediction under a=0
    y1_hat = inverse_logit(np.dot(X1, beta))  # Prediction under a=1

    # Estimating function for causal risk under a=1
    ee_r1 = y1_hat - mu1             # Simple mean

    # Estimating function for causal risk under a=0
    ee_r0 = y0_hat - mu0             # Simple mean

    # Estimating function for causal risk difference
    ee_rd = np.ones(d.shape[0])*((mu1 - mu0) - delta1)

    # Returning stacked estimating functions in order of parameters
    return np.vstack([ee_logit,   # EF of logistic model
                      ee_r0,      # EF of causal risk a=0
                      ee_r1,      # EF of causal risk a=1
                      ee_rd])     # EF of causal contrast
[9]:
estr = MEstimator(psi, init=[0, 0, 0, 0.5, 0.5, 0])
estr.estimate()
[10]:
# Formatting results into a nice table
result = pd.DataFrame()
result['Param'] = ['beta_0', 'beta_1', 'beta_2', 'mu_0', 'mu_1', 'delta']
result['Coef'] = estr.theta
ci = estr.confidence_intervals()
result['LCL'] = ci[:, 0]
result['UCL'] = ci[:, 1]
result.round(2)
[10]:
Param Coef LCL UCL
0 beta_0 -1.89 -2.13 -1.66
1 beta_1 0.12 -0.43 0.67
2 beta_2 0.36 -0.11 0.83
3 mu_0 0.14 0.11 0.17
4 mu_1 0.15 0.09 0.22
5 delta 0.01 -0.06 0.09

These results match those reported in the publication (some differences in rounding by the chosen root-finding algorithm).

Now consider estimating the marginal risk difference using inverse probability weighting. The following code implements this estimator by-hand

[11]:
a = np.asarray(d['X'])
W = np.asarray(d[['intercept', 'W']])
[12]:
def psi(theta):
    # Dividing parameters into corresponding parts and labels from slides
    alpha = theta[0:2]              # Logistic model coefficients
    mu0, mu1 = theta[2], theta[3]   # Causal risks
    delta1 = theta[4]               # Causal contrast

    # Logistic regression model for propensity score
    ee_logit = ee_regression(theta=alpha,       # Regression model
                             y=a,               # ... for exposure
                             X=W,               # ... given confounders
                             model='logistic')  # ... logistic model

    # Transforming logistic model coefficients into causal parameters
    pscore = inverse_logit(np.dot(W, alpha))    # Propensity score
    wt = d['X']/pscore + (1-d['X'])/(1-pscore)  # Corresponding weights

    # Estimating function for causal risk under a=1
    ee_r1 = d['X']*d['Y']*wt - mu1         # Weighted conditional mean

    # Estimating function for causal risk under a=0
    ee_r0 = (1-d['X'])*d['Y']*wt - mu0     # Weighted conditional mean

    # Estimating function for causal risk difference
    ee_rd = np.ones(d.shape[0])*((mu1 - mu0) - delta1)

    # Returning stacked estimating functions in order of parameters
    return np.vstack([ee_logit,   # EF of logistic model
                      ee_r0,      # EF of causal risk a=0
                      ee_r1,      # EF of causal risk a=1
                      ee_rd])     # EF of causal contrast
[13]:
estr = MEstimator(psi, init=[0, 0, 0.5, 0.5, 0])
estr.estimate()
[14]:
# Formatting results into a nice table
result = pd.DataFrame()
result['Param'] = ['alpha_0', 'alpha_1', 'mu_0', 'mu_1', 'delta']
result['Coef'] = estr.theta
ci = estr.confidence_intervals()
result['LCL'] = ci[:, 0]
result['UCL'] = ci[:, 1]
result.round(2)
[14]:
Param Coef LCL UCL
0 alpha_0 -1.74 -1.95 -1.53
1 alpha_1 -0.30 -0.83 0.24
2 mu_0 0.14 0.11 0.17
3 mu_1 0.15 0.09 0.22
4 delta 0.01 -0.06 0.08

Again, these results match those reported in the publication

Example 3: Outcome misclassification

For the final example

[15]:
# Loading in data for the fusion example
d = pd.DataFrame()
d['R'] = [1, 1, 0, 0, 0, 0]           # R or population indicator
d['Y'] = [0, 0, 1, 1, 0, 0]           # True outcome
d['W'] = [1, 0, 1, 0, 1, 0]           # Measured outcome
d['n'] = [680, 270, 204, 38, 18, 71]  # Counts
d['intercept'] = 1                    # Intercept is always 1

# Expanding out data
d = pd.DataFrame(np.repeat(d.values, d['n'], axis=0),  # Expanding compact data frame
                 columns=d.columns)                    # ... keeping column names
d = d[['intercept', 'R', 'W', 'Y']].copy()             # Dropping the n column

# Converting to arrays to simplify process
r = np.asarray(d['R'])
w = np.asarray(d['W'])
y = np.asarray(d['Y'])
[16]:
def psi(theta):
    return ee_rogan_gladen(theta=theta,    # Parameter of interest
                           y=d['Y'],       # ... correct measure
                           y_star=d['W'],  # ... mismeasure
                           r=d['R'])       # ... sample indicator
[17]:
estr = MEstimator(psi, init=[0.75, 0.75, 0.75, 0.75])
estr.estimate()
[18]:
# Formatting results into a nice table
result = pd.DataFrame()
result['Param'] = ['Corrected', 'Mismeasured', 'Sensitivity', 'Specificity']
result['Coef'] = estr.theta
ci = estr.confidence_intervals()
result['LCL'] = ci[:, 0]
result['UCL'] = ci[:, 1]
result.round(2)
[18]:
Param Coef LCL UCL
0 Corrected 0.80 0.72 0.88
1 Mismeasured 0.72 0.69 0.74
2 Sensitivity 0.84 0.80 0.89
3 Specificity 0.80 0.71 0.88

These results match Ross et al.

Conclusions

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.

References

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.