4. Baseline Logistic Regression Benchmark for Injury Prediction
Why a Logistic Regression Floor Matters
Before any ensemble model is trained, the injury-prediction pipeline needs a defensible lower bound. A regularized logistic regression baseline answers a specific engineering question: how much predictive signal can a linear, additive model extract from the leakage-safe feature set? If Random Forest or XGBoost cannot beat this floor on validation AUC and severe-injury recall, the added complexity is not justified. The baseline also validates the preprocessing contract — encoding, scaling, and class weighting — that every later model will reuse.
This case builds that baseline end to end: a scikit-learn Pipeline with median imputation, one-hot encoding for categorical crash fields, standardization for continuous occupant and crash variables, and an L2-penalized logistic regression with balanced class weights. The output is a reproducible metric record (validation AUC, PR-AUC, severe-class recall at a fixed threshold) that becomes the reference row in the model comparison table.
Preprocessing Contract
The feature set from the prior case contains three families: continuous occupant measures (age, BMI, seated height), continuous crash measures (delta-V, principal direction of force), and categorical fields (seat position, belt usage, vehicle body type, crash configuration). Logistic regression requires all inputs to be numeric and on comparable scales, so the pipeline must handle each family explicitly.
- Numeric columns — median imputation (robust to the heavy right tail in delta-V), then
StandardScaler. Scaling is mandatory: L2 regularization penalizes coefficients on their native scale, so an unscaled delta-V in km/h would be shrunk far less than a belt-usage indicator in {0,1}. - Categorical columns — most-frequent imputation, then
OneHotEncoder(handle_unknown="ignore"). Theignoresetting prevents a crash at inference time from raising on an unseen body type. - Class imbalance —
class_weight="balanced"reweights the loss inversely to class frequency, which shifts the decision boundary toward the rare severe-injury class without resampling the data.
All preprocessing lives inside the pipeline so that fit statistics (medians, means, standard deviations, category vocabularies) are learned on the training fold only. Fitting a scaler on the full dataset before splitting is the most common leakage bug in this kind of study and will inflate validation metrics.
Reference Implementation
The following script is self-contained: it defines a synthetic-but-realistic injury dataset with the same column contract as the leakage-safe feature set, performs the stratified split, fits the pipeline, and reports the benchmark metrics. Replace load_injury_frame() with the real loader; everything downstream is unchanged.
import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import (roc_auc_score, average_precision_score,
recall_score, precision_score, confusion_matrix)
RNG = np.random.default_rng(42)
def load_injury_frame(n=6000):
"""Stand-in for the real loader; same schema as the leakage-safe set."""
df = pd.DataFrame({
"age": RNG.normal(42, 16, n).clip(16, 90),
"bmi": RNG.normal(26, 4.5, n).clip(16, 45),
"delta_v_kph": RNG.gamma(2.0, 12.0, n),
"pdoF_deg": RNG.uniform(0, 360, n),
"seat_position": RNG.choice(["driver", "front_pass", "rear"], n,
p=[0.55, 0.30, 0.15]),
"belt_used": RNG.choice([0, 1], n, p=[0.18, 0.82]),
"body_type": RNG.choice(["sedan", "suv", "pickup", "van"], n,
p=[0.45, 0.30, 0.15, 0.10]),
})
# Latent severity: delta-V and unbelted status dominate.
logit = (-6.0 + 0.055 * df["delta_v_kph"]
- 1.4 * df["belt_used"]
+ 0.012 * (df["age"] - 42))
p = 1 / (1 + np.exp(-logit))
df["severe_injury"] = (RNG.random(n) < p).astype(int)
return df
NUMERIC = ["age", "bmi", "delta_v_kph", "pdoF_deg"]
CATEGORICAL = ["seat_position", "belt_used", "body_type"]
numeric_pipe = Pipeline([
("impute", SimpleImputer(strategy="median")),
("scale", StandardScaler()),
])
categorical_pipe = Pipeline([
("impute", SimpleImputer(strategy="most_frequent")),
("onehot", OneHotEncoder(handle_unknown="ignore")),
])
preprocess = ColumnTransformer([
("num", numeric_pipe, NUMERIC),
("cat", categorical_pipe, CATEGORICAL),
])
baseline = Pipeline([
("prep", preprocess),
("clf", LogisticRegression(
penalty="l2", C=1.0, class_weight="balanced",
solver="lbfgs", max_iter=2000, random_state=42)),
])
df = load_injury_frame()
X, y = df[NUMERIC + CATEGORICAL], df["severe_injury"]
X_tr, X_tmp, y_tr, y_tmp = train_test_split(
X, y, test_size=0.30, stratify=y, random_state=42)
X_val, X_te, y_val, y_te = train_test_split(
X_tmp, y_tmp, test_size=0.50, stratify=y_tmp, random_state=42)
baseline.fit(X_tr, y_tr)
def report(split, Xs, ys, threshold=0.5):
proba = baseline.predict_proba(Xs)[:, 1]
pred = (proba >= threshold).astype(int)
tn, fp, fn, tp = confusion_matrix(ys, pred).ravel()
return {
"split": split,
"roc_auc": round(roc_auc_score(ys, proba), 4),
"pr_auc": round(average_precision_score(ys, proba), 4),
"severe_recall": round(recall_score(ys, pred), 4),
"severe_precision": round(precision_score(ys, pred), 4),
"tp": int(tp), "fn": int(fn),
}
for name, Xs, ys in [("train", X_tr, y_tr),
("val", X_val, y_val),
("test", X_te, y_te)]:
print(report(name, Xs, ys))
Two details are worth calling out. First, C=1.0 is the default inverse regularization strength; it is a deliberate starting point, not a tuned value. Second, the threshold is fixed at 0.5 for the benchmark record. Because class_weight="balanced" already shifts the probability distribution, 0.5 is not the operating point you would deploy — but it is a consistent reference that every later model must be compared against at the same threshold.
Reading the Benchmark Record
The printed rows give three views of the same model. The train row establishes that the pipeline can fit the data at all; a train AUC near 0.5 signals a broken feature contract (wrong column names, all-NaN column, or a target that leaked into the features). The validation row is the number that enters the comparison table. The test row is held back and should not be inspected until the final model is chosen — peeking at it during model selection turns it into a second validation set.
For an imbalanced injury target, ROC-AUC alone is misleading because it is dominated by the abundant non-severe class. The benchmark record therefore pairs it with PR-AUC (average precision) and severe-class recall. A baseline that achieves, say, ROC-AUC 0.78 but severe recall 0.41 at threshold 0.5 tells you the linear model ranks cases reasonably but under-fires on the rare class — exactly the gap a tree ensemble is expected to close.
C, threshold, and all four metrics — in the comparison table before training any ensemble. If you re-run the baseline later with a different seed, the floor moves and every downstream comparison becomes ambiguous.Interpreting the Pipeline Structure
The diagram below shows how the raw crash and occupant fields flow through the column transformer into the regularized classifier, and which artifacts are learned at fit time versus applied at inference. The key point is that the scaler statistics and encoder vocabulary are fitted parameters stored inside the pipeline, not global constants — this is what makes the baseline leakage-safe and reusable by the ensemble models.
Verification Checklist
Before accepting the baseline row, confirm the following. Each item catches a specific failure mode that would otherwise corrupt every downstream comparison.
| Check | Expected observation | Failure symptom |
|---|---|---|
| Train AUC is above chance | Train ROC-AUC clearly > 0.5 | ≈0.5 → feature/target mismatch or all-NaN column |
| Validation gap is small | Train − val AUC within a few points | Large gap → leakage or overfitting; recheck split |
| Severe recall is non-trivial | Recall > 0 at threshold 0.5 | 0 recall → class weighting not applied or threshold too high |
| Pipeline is refittable | baseline.fit on a new fold reproduces metrics | Metrics drift → preprocessing fitted outside pipeline |
| Unknown categories tolerated | Predicting a row with a new body_type does not raise | Exception → handle_unknown not set to "ignore" |
C against the test set. If you want to explore regularization strength, sweep it on the validation fold and record the chosen value alongside the benchmark row. The test set is reserved for the final selected model, and the baseline's test metrics should be computed once, after the comparison table is frozen.With the baseline row recorded, the pipeline object itself becomes a reusable asset: the Random Forest and XGBoost cases can wrap the same preprocess transformer (tree models do not need scaling, but sharing the imputation and encoding logic keeps the feature contract identical) and their validation metrics are directly comparable to the floor established here.