393 lines
10 KiB
Markdown
393 lines
10 KiB
Markdown
---
|
|
title: "Logistic Regression Reference"
|
|
task: ""
|
|
lineage_type: import
|
|
upstream_source: https://github.com/mims-harvard/ToolUniverse/blob/e2520a96/skills/tooluniverse-statistical-modeling/references/logistic_regression.md
|
|
upstream_sha: e2520a96
|
|
imported_at: 2026-06-26
|
|
prompt_class: prompt
|
|
upstream_changes: accepted
|
|
author: upstream
|
|
validated: false
|
|
---
|
|
|
|
# Logistic Regression Reference
|
|
|
|
Complete guide to binary logistic regression for biomedical data analysis.
|
|
|
|
## Binary Logistic Regression
|
|
|
|
### Basic Model
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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):
|
|
|
|
```python
|
|
# 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:
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
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))
|
|
```
|