Bankruptcy Risk Prediction
PythonXGBoostSHAPScikit-learn
The problem
Predict which companies are at risk of bankruptcy from a financial dataset of 6,819 samples and 94 features, with roughly 3% of firms actually bankrupt. Traditional ratio-based screening does not capture nonlinear risk patterns, and in this domain a missed bankruptcy is far more costly than a false alarm, so the model had to be built around that asymmetry from the start.
My approach
- Statistical validity check: with close to 6,800 rows, almost every feature comes back statistically significant by p-value alone, which makes p-value meaningless at this sample size. Effect size (Cohen's d) was used instead to judge which features actually mattered.
- Feature reduction: cut from 94 features to 58 using three separate techniques, manual removal of known-redundant financial ratios, algorithmic pruning of highly correlated pairs guided by effect size, and dropping near-zero-variance features, rather than one blunt cutoff.
- Deliberately no outlier removal. With a dataset this small, and no way to know whether a future test set would show the same extreme values, removing outliers risked deleting genuine cases of financial distress rather than noise. Doing nothing was the more defensible choice here, not the default one.
- Architecture: compared Logistic Regression against XGBoost rather than assuming a tree-based model was correct from the start. Class imbalance was handled with class weighting rather than synthetic oversampling.
- Trade-off and result: XGBoost was then hyperparameter-tuned with a full grid search. The tuned model performed worse than the untuned baseline XGBoost once both were evaluated with a properly tuned decision threshold. The baseline model was shipped instead of the tuned one.
- Technical decision: the final decision threshold was optimised using F2 score rather than the default 0.5, since F2 weights recall above precision. In this domain, missing a real bankruptcy is far costlier than flagging a healthy firm for review, so the threshold was chosen to reflect that cost, not to maximise a generic accuracy number.
- Validation: cross-checked feature importance using three independent methods, XGBoost's native importance, permutation importance, and SHAP. All three agreed on the same top 4 drivers, which is what confirmed the signal was real financial structure and not noise from a small dataset.
Results
- Final model, baseline XGBoost at a tuned threshold of 0.08, achieved ROC-AUC of 0.96 and 73% recall on bankrupt firms.
- Produced roughly half the false alarms of the Logistic Regression baseline, a 2x reduction, at a comparable recall level.
- The top 4 SHAP-confirmed risk drivers, poor earnings stability, weak liquidity, high leverage, and high interest burden, were translated into risk-tiered recommendations for high, medium, and low-risk firms.
What I would do differently
The F2-weighted threshold was tuned as a general-purpose stand-in for the real cost of a missed bankruptcy versus a false alarm. I never had the actual cost figures from a real institution to tune against. Next time, I would push to get that cost ratio from a domain stakeholder first, so the threshold reflects the real cost function instead of a reasonable proxy for it.