Files
drug-discovery-prompts/upstream/mims-harvard-ToolUniverse/skills/tooluniverse-statistical-modeling/references/logistic_regression.md

10 KiB

title, task, lineage_type, upstream_source, upstream_sha, imported_at, prompt_class, upstream_changes, author, validated
title task lineage_type upstream_source upstream_sha imported_at prompt_class upstream_changes author validated
Logistic Regression Reference import https://github.com/mims-harvard/ToolUniverse/blob/e2520a96/skills/tooluniverse-statistical-modeling/references/logistic_regression.md e2520a96 2026-06-26 prompt accepted upstream false

Logistic Regression Reference

Complete guide to binary logistic regression for biomedical data analysis.

Binary Logistic Regression

Basic Model

import pandas as pd
import numpy as np
import statsmodels.api as sm
import statsmodels.formula.api as smf

# Load data
df = pd.read_csv('clinical_data.csv')

# Method 1: Formula API (recommended)
model = smf.logit('disease ~ exposure + age + sex', data=df).fit(disp=0)

# Method 2: Matrix API
X = sm.add_constant(df[['exposure', 'age', 'sex']])
y = df['disease']
model = sm.Logit(y, X).fit(disp=0)

# Print summary
print(model.summary())

Extracting Odds Ratios

# Coefficients (log odds)
coefs = model.params
print("Log odds (coefficients):")
print(coefs)

# Odds ratios (exponentiate coefficients)
odds_ratios = np.exp(model.params)
print("\nOdds ratios:")
print(odds_ratios)

# 95% Confidence intervals
conf_int = model.conf_int()
conf_int_exp = np.exp(conf_int)

# Pretty print
for var in model.params.index:
    or_val = odds_ratios[var]
    ci_lower = conf_int_exp.loc[var, 0]
    ci_upper = conf_int_exp.loc[var, 1]
    p_val = model.pvalues[var]

    print(f"\n{var}:")
    print(f"  OR: {or_val:.4f}")
    print(f"  95% CI: ({ci_lower:.4f}, {ci_upper:.4f})")
    print(f"  p-value: {p_val:.6f}")
    print(f"  Significant: {'Yes' if p_val < 0.05 else 'No'}")

Model Fit Statistics

# Pseudo R-squared
print(f"McFadden's R²: {model.prsquared:.4f}")

# Log-likelihood
print(f"Log-likelihood: {model.llf:.4f}")

# AIC/BIC
print(f"AIC: {model.aic:.2f}")
print(f"BIC: {model.bic:.2f}")

# Number of observations
print(f"N: {int(model.nobs)}")

Categorical Predictors

Manual Dummy Coding

# Create dummy variables
df_encoded = pd.get_dummies(df, columns=['treatment_group'], drop_first=True, dtype=int)

# Fit model
model = smf.logit('disease ~ treatment_group_B + treatment_group_C + age',
                  data=df_encoded).fit(disp=0)

Formula with Categorical Variables

# statsmodels handles categorical automatically with C()
model = smf.logit('disease ~ C(treatment_group) + age', data=df).fit(disp=0)

# Set reference level
model = smf.logit('disease ~ C(treatment_group, Treatment("Control")) + age',
                  data=df).fit(disp=0)

Interaction Terms

Two-Way Interactions

# Interaction between continuous and binary
model = smf.logit('disease ~ exposure * age + sex', data=df).fit(disp=0)

# The interaction term is 'exposure:age'
interaction_coef = model.params['exposure:age']
interaction_or = np.exp(interaction_coef)
print(f"Interaction OR: {interaction_or:.4f}")

Interpreting Interactions

# Main effects + interaction
# Model: logit(p) = β0 + β1*exposure + β2*age + β3*exposure*age

# OR for exposure depends on age:
# OR(exposure) = exp(β1 + β3*age)

# Example: OR at age=30 vs age=50
beta_exposure = model.params['exposure']
beta_interaction = model.params['exposure:age']

or_age30 = np.exp(beta_exposure + beta_interaction * 30)
or_age50 = np.exp(beta_exposure + beta_interaction * 50)

print(f"OR (exposure) at age 30: {or_age30:.4f}")
print(f"OR (exposure) at age 50: {or_age50:.4f}")

Adjusted vs Unadjusted Analysis

Percentage Reduction in OR

# Unadjusted (crude) model
model_crude = smf.logit('disease ~ exposure', data=df).fit(disp=0)
or_crude = np.exp(model_crude.params['exposure'])

# Adjusted model
model_adj = smf.logit('disease ~ exposure + age + sex + bmi', data=df).fit(disp=0)
or_adj = np.exp(model_adj.params['exposure'])

# Calculate percentage reduction
pct_reduction = (or_crude - or_adj) / or_crude * 100

print(f"Crude OR: {or_crude:.4f}")
print(f"Adjusted OR: {or_adj:.4f}")
print(f"Percentage reduction: {pct_reduction:.1f}%")

# Interpretation
if pct_reduction > 10:
    print("Strong confounding detected")
elif pct_reduction > 5:
    print("Moderate confounding")
else:
    print("Minimal confounding")

Model Comparison

Likelihood Ratio Test

from scipy import stats

# Nested models
model_reduced = smf.logit('disease ~ exposure', data=df).fit(disp=0)
model_full = smf.logit('disease ~ exposure + age + sex + bmi', data=df).fit(disp=0)

# LR test statistic
lr_stat = -2 * (model_reduced.llf - model_full.llf)
df_diff = model_full.df_model - model_reduced.df_model
p_value = stats.chi2.sf(lr_stat, df_diff)

print(f"LR statistic: {lr_stat:.4f}")
print(f"df: {df_diff}")
print(f"p-value: {p_value:.6f}")

if p_value < 0.05:
    print("Full model provides significantly better fit")
else:
    print("Additional predictors not significant")

AIC/BIC Comparison

# Fit multiple models
models = {
    'Model 1': smf.logit('disease ~ exposure', data=df).fit(disp=0),
    'Model 2': smf.logit('disease ~ exposure + age', data=df).fit(disp=0),
    'Model 3': smf.logit('disease ~ exposure + age + sex', data=df).fit(disp=0),
}

# Compare
print("Model Comparison:")
for name, m in models.items():
    print(f"\n{name}:")
    print(f"  AIC: {m.aic:.2f}")
    print(f"  BIC: {m.bic:.2f}")
    print(f"  Pseudo R²: {m.prsquared:.4f}")

# Best model (lowest AIC)
best_model = min(models.items(), key=lambda x: x[1].aic)
print(f"\nBest model (by AIC): {best_model[0]}")

Prediction

Predicted Probabilities

# Get predicted probabilities for existing data
df['predicted_prob'] = model.predict(df)

# Predict for new data
new_data = pd.DataFrame({
    'exposure': [1],
    'age': [45],
    'sex': ['M']
})
pred_prob = model.predict(new_data)
print(f"Predicted probability: {pred_prob[0]:.4f}")

Classification

# Binary classification with 0.5 threshold
df['predicted_class'] = (model.predict(df) > 0.5).astype(int)

# Confusion matrix
from sklearn.metrics import confusion_matrix, classification_report
cm = confusion_matrix(df['disease'], df['predicted_class'])
print("Confusion Matrix:")
print(cm)

# Accuracy, precision, recall
print("\nClassification Report:")
print(classification_report(df['disease'], df['predicted_class']))

Diagnostics

Influential Observations

# Cook's distance
from statsmodels.stats.outliers_influence import OLSInfluence

# Get influence measures
influence = model.get_influence()

# Standardized residuals
std_resid = influence.resid_studentized

# Plot influential points
import matplotlib.pyplot as plt
plt.scatter(range(len(std_resid)), std_resid)
plt.axhline(y=2, color='r', linestyle='--')
plt.axhline(y=-2, color='r', linestyle='--')
plt.xlabel('Observation')
plt.ylabel('Studentized Residual')
plt.title('Influential Observations')
plt.show()

Hosmer-Lemeshow Test

# Goodness of fit test
from statsmodels.stats.diagnostic import _diagnostic_hl

# Not directly available in statsmodels
# Use manual implementation
def hosmer_lemeshow_test(y_true, y_pred, g=10):
    """Hosmer-Lemeshow goodness of fit test."""
    data = pd.DataFrame({'y': y_true, 'pred': y_pred})
    data['decile'] = pd.qcut(data['pred'], g, duplicates='drop')

    obs = data.groupby('decile')['y'].agg(['sum', 'count'])
    exp = data.groupby('decile')['pred'].agg(['sum', 'count'])

    hl_stat = ((obs['sum'] - exp['sum'])**2 / (exp['sum'] * (1 - exp['sum']/exp['count']))).sum()
    p_value = stats.chi2.sf(hl_stat, g-2)

    return hl_stat, p_value

hl_stat, hl_p = hosmer_lemeshow_test(df['disease'], model.predict(df))
print(f"Hosmer-Lemeshow: χ²={hl_stat:.4f}, p={hl_p:.6f}")

Common Issues

Separation (Perfect Prediction)

Problem: One predictor perfectly predicts the outcome.

Solution: Use Firth logistic regression (penalized likelihood):

# This requires logistf package (not in standard statsmodels)
# Alternative: Remove the problematic predictor or use regularization

# Check for separation
print("Value counts by predictor:")
print(pd.crosstab(df['exposure'], df['disease']))

# If separation detected, try Ridge logistic
from sklearn.linear_model import LogisticRegression
lr = LogisticRegression(penalty='l2', C=1.0)
lr.fit(df[['exposure', 'age', 'sex']], df['disease'])

Convergence Failure

Problem: Model doesn't converge (common with small samples or collinearity).

Solution: Increase max iterations or check for collinearity:

# Increase iterations
model = smf.logit('disease ~ exposure + age + sex', data=df).fit(disp=0, maxiter=200)

# Check for collinearity
from statsmodels.stats.outliers_influence import variance_inflation_factor
X = df[['exposure', 'age', 'sex']].copy()
X = pd.get_dummies(X, drop_first=True, dtype=float)
X['const'] = 1

for i, col in enumerate(X.columns[:-1]):
    vif = variance_inflation_factor(X.values, i)
    print(f"{col}: VIF={vif:.2f}")

Quasi-Complete Separation

Problem: Predictor almost perfectly separates outcomes.

Symptoms: Very large coefficients (>10), very large standard errors.

Solution: Use regularization or remove problematic predictor.

Reporting Template

def report_logistic_regression(model):
    """Generate publication-quality report."""
    report = []
    report.append("=== Logistic Regression Results ===\n")
    report.append(f"N = {int(model.nobs)}")
    report.append(f"Pseudo R² = {model.prsquared:.4f}")
    report.append(f"AIC = {model.aic:.2f}\n")

    report.append("Odds Ratios (95% CI):\n")
    ors = np.exp(model.params)
    ci = np.exp(model.conf_int())

    for var in model.params.index:
        if var == 'Intercept':
            continue
        or_val = ors[var]
        ci_lower = ci.loc[var, 0]
        ci_upper = ci.loc[var, 1]
        p_val = model.pvalues[var]
        sig = "*" if p_val < 0.05 else ""

        report.append(f"  {var}: OR={or_val:.4f} ({ci_lower:.4f}-{ci_upper:.4f}), p={p_val:.4f}{sig}")

    return "\n".join(report)

print(report_logistic_regression(model))