An end-to-end data analytics project that predicts which clients are most likely to take up an investment product, then ranks and routes those leads to advisors under realistic capacity constraints, turning a marketing list into a prioritised call sheet.
A worked example of propensity modelling for retail-banking direct-marketing campaigns: predict product take-up, rank leads by likelihood to convert, and route limited advisor capacity to maximise conversions per contact.
Working only the top 20% of scored leads captures ~66% of all conversions: 3.3× more than random targeting, at 3.8× the value per advisor call.
See the full write-up in reports/executive_summary.md.
Advisors can only call a fraction of available leads. Treating every prospect as equally promising wastes the scarcest resource: advisor time. A propensity model that ranks leads lets the team concentrate effort where it converts, driving both acquisition (new subscribers) and retention (re-engaging warm prior-campaign clients).
UCI Bank Marketing
(bank-additional-full.csv): 41,188 real direct-marketing contacts from a
retail bank, with client demographics, campaign/contact history, and
macro-economic indicators. The term-deposit subscription target stands in for
mutual-fund take-up. Base conversion rate: 11.3%.
Not committed to the repo (see
.gitignore). Re-fetch with the command in Reproduce.
The data is publicly released for research and is de-identified: it contains no names, account numbers, phone numbers, or addresses, only coarse demographic and campaign attributes. The dataset documentation notes that some attributes were withheld from the original source "due to privacy concerns," and the records date from 2008 to 2010. It is used here strictly for educational analysis; any production use of comparable client data would fall under applicable privacy law (such as PIPEDA or GDPR), data governance, and access controls.
Moro, S., Cortez, P., & Rita, P. (2014). A Data-Driven Approach to Predict the Success of Bank Telemarketing. Decision Support Systems, 62, 22-31. https://doi.org/10.1016/j.dss.2014.03.001
data/raw (CSV)
│ src/load_to_sqlite.py
▼
SQLite ──► sql/eda_queries.sql # SQL layer: business EDA
│ src/features.py # leakage handling + engineered fields
▼
src/train.py ──► models/ # LogReg baseline + XGBoost challenger
│ reports/metrics.json
▼
src/prioritize.py ──► ranked leads + ROI / strategy comparison
│
▼
src/visuals.py ──► reports/figures/ + dashboard/ (Tableau extracts)
| Stage | Script | Purpose |
|---|---|---|
| Ingestion | load_to_sqlite.py |
Load raw data into a queryable SQLite store |
| EDA | sql/eda_queries.sql |
Surface conversion trends by segment, channel, season |
| Features | features.py |
Derive fields and remove target leakage |
| Modelling | train.py |
Train and select the lead-scoring model |
| Lead prioritisation | prioritize.py |
Rank leads and quantify value vs. baselines |
| Reporting | visuals.py, reports/ |
Figures, dashboard extracts, executive summary |
- Leakage dropped.
duration(call length) is excluded: it is unknown before a call, so using it would inflate metrics and be useless for targeting. Honest held-out ROC-AUC: 0.81. - Ranking over accuracy. A "predict no for everyone" model scores ~89% accuracy and is worthless. We optimise PR-AUC and top-decile lift instead.
- Class imbalance (11% positive) handled via
class_weight/scale_pos_weight. - Interpretable baseline. Logistic Regression is kept alongside XGBoost; in a regulated bank, an explainable model matters.
| Model | ROC-AUC | PR-AUC | Top-10% precision | Top-10% lift |
|---|---|---|---|---|
| Logistic Regression | 0.80 | 0.46 | 51% | 4.5× |
| XGBoost (selected) | 0.81 | 0.49 | 53% | 4.7× |
- Generalisation is checked, not assumed.
src/diagnostics.pyreports the train/test gap and 5-fold cross-validation: XGBoost lands at train 0.83 vs test 0.81 (gap ~0.02) with CV 0.796 +/- 0.003, and the logistic baseline shows effectively no gap. All estimates cluster at 0.79 to 0.81, so the held-out headline is not a lucky split. - Early stopping + regularisation. XGBoost is fit with early stopping on a
validation slice (selecting ~183 trees) plus shallow depth, row/column
subsampling,
min_child_weight, and L2, keeping the train/test gap near 0.02. - Multicollinearity inspected.
src/correlation_check.pywrites a correlation heatmap and VIF table. The macro-economic features are collinear by construction (VIF > 30), which is expected and harmless for a tree model, so no features are dropped on correlation grounds.
dashboard/ holds Tableau/Power BI-ready extracts (decile gains, strategy
comparison, segment / channel / monthly / prior-outcome performance) plus the
full scored lead list (data/processed/prioritized_leads.csv) for an
interactive lead-prioritisation view.
# 1. environment
python -m venv .venv && .venv/Scripts/python -m pip install -r requirements.txt
# 2. data
curl -L -o data/raw/bank+marketing.zip \
"https://archive.ics.uci.edu/static/public/222/bank+marketing.zip"
# unzip the nested bank-additional.zip and place bank-additional-full.csv in data/raw/
# 3. run the pipeline
.venv/Scripts/python src/load_to_sqlite.py
.venv/Scripts/python src/train.py
.venv/Scripts/python src/prioritize.py
.venv/Scripts/python src/visuals.py
# optional: validation + multicollinearity checks
.venv/Scripts/python src/diagnostics.py
.venv/Scripts/python src/correlation_check.pyPython (pandas, scikit-learn, XGBoost, SHAP, matplotlib) · SQL (SQLite) · Tableau/Power BI (dashboard extracts).
- AWS deployment: raw data in S3, query via Athena, scoring on Lambda/SageMaker
- Published interactive Tableau Public dashboard
- Model monitoring (drift) + fairness review
- R / SAS parallel implementation of the scoring model
