Spaces:
Sleeping
Sleeping
Upload 7 files
Browse files- 1_π―_Business_case.py +36 -0
- pages/2_π_Data_handling_and_feature_engineering.py +117 -0
- pages/3_π_EDA_and_visualization.py +187 -0
- pages/4_π_Model_interpretation.py +114 -0
- pages/5_π²_Magenment_deck.py +73 -0
- pages/6_π_Callcenter_dashboard.py +235 -0
- requirements.txt +12 -0
1_π―_Business_case.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import numpy as np
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
st.set_page_config(page_title="Banking marketing campaign", page_icon=":phone:", layout="wide")
|
| 7 |
+
|
| 8 |
+
st.markdown("""
|
| 9 |
+
# Business problem:
|
| 10 |
+
### We're a bank trying to reach potential customers to subscribe to a term deposit.
|
| 11 |
+
|
| 12 |
+
Currently, the bank spends over **$220,000** contacting customers who ultimately **do not purchase** the term deposit β indicating inefficiency in campaign targeting.
|
| 13 |
+
|
| 14 |
+
Our project focuses on improving the **return on investment (ROI)** of these marketing efforts by:
|
| 15 |
+
- Identifying which customer segments are most likely to subscribe.
|
| 16 |
+
- Reducing wasted expenditure on customers unlikely to convert.
|
| 17 |
+
- Simulating different cost and profit scenarios to evaluate potential improvements.
|
| 18 |
+
|
| 19 |
+
The model uses a dataset that includes:
|
| 20 |
+
- **Demographics:** age, marital status, education level.
|
| 21 |
+
- **Financial details:** account balance, housing loans, personal loans.
|
| 22 |
+
- **Campaign history:** number of contacts, previous outcomes, and time since last contact.
|
| 23 |
+
- **Call information:** month and duration of the last contact.
|
| 24 |
+
|
| 25 |
+
By predicting the **probability of a new customer subscribing**, the model supports better targeting decisions.
|
| 26 |
+
It also informs the **call centre incentive structure** β offering higher bonuses for converting low-probability customers, encouraging efficiency and motivation among staff.
|
| 27 |
+
|
| 28 |
+
It's a direct to consumer marketing case, and our plan is to most effectively use our marketing budget.
|
| 29 |
+
|
| 30 |
+
# Stakeholders
|
| 31 |
+
### - Management: Want to maximise ROI by understanding where marketing spend generates the most value and how to allocate resources effectively.
|
| 32 |
+
### - Marketing team leaders: Seek insights into which customer segments and campaign strategies yield higher conversion rates, enabling smarter decision-making and more efficient campaigns.
|
| 33 |
+
### - Call centre staff: Use model predictions to prioritise calls, improve success rates, and align bonuses with the difficulty of conversion β ensuring fair rewards for high-effort sales.
|
| 34 |
+
|
| 35 |
+
""")
|
| 36 |
+
|
pages/2_π_Data_handling_and_feature_engineering.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import time
|
| 3 |
+
import numpy as np
|
| 4 |
+
|
| 5 |
+
st.set_page_config(page_title="Data handling and feature", page_icon="π")
|
| 6 |
+
|
| 7 |
+
container1 = st.container(border=True, vertical_alignment="center")
|
| 8 |
+
container2 = st.container(border=True, vertical_alignment="center")
|
| 9 |
+
|
| 10 |
+
with container1:
|
| 11 |
+
col1, col2 =st.columns([0.4, 0.6])
|
| 12 |
+
with col1:
|
| 13 |
+
st.markdown("""
|
| 14 |
+
# Explanation of dataset
|
| 15 |
+
The dataset can be broken down into three main components.
|
| 16 |
+
|
| 17 |
+
There's the client attributes. These are the standard information the bank would have, presuming that we're calling our existing customer base, and not cold-calling for new customers.
|
| 18 |
+
It's mainly categorical features, with age and balance being numerical.
|
| 19 |
+
There are some categorical values that are almost bolean with nan/unknow values.
|
| 20 |
+
|
| 21 |
+
Then we have our contact attributes. These are features regarding how a customer was contacted, on what day and which month, and for how long the contact was.
|
| 22 |
+
We checked the dataset, and found that the collection had found place from may 2008 to november 2010. Quickly we realized that this suggest the data has some temporal features to it. As we can see we have the month and the day of month as variables. We know from the dataset that it started may 2008, and is in descending order,
|
| 23 |
+
so the year is implicitly in the data, with the first grouping of may belonging to 2008, the next to 2009 etc. for every month. From that we can build a datetime variable.
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
Lastly there's campaign attributes. There's been a previous campaign (later we will see, some sample imbalance because of this) and the campaign feature captures the number of contacts during the campaign. Pdays is the number of days since the customer was last contacted, previous the number of times, and poutcome the outcome of the previous campaign.
|
| 27 |
+
|
| 28 |
+
Importantly, the pdays is -1 if the customer has not been contacted before, meaning that a -1 in this feature is different from any other value, and we will later engineer a feature to capture this.
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
""")
|
| 32 |
+
|
| 33 |
+
with col2:
|
| 34 |
+
st.markdown("""
|
| 35 |
+
|
| 36 |
+
""")
|
| 37 |
+
|
| 38 |
+
st.markdown("""
|
| 39 |
+
|
| 40 |
+
## Original data
|
| 41 |
+
### Client Atributes
|
| 42 |
+
|
| 43 |
+
| Column | Description |
|
| 44 |
+
| ----------- | ---------------------------------------------------------------------------------------------- |
|
| 45 |
+
| `age` | Age of the client (numeric). |
|
| 46 |
+
| `job` | Type of job (categorical). Examples: `admin.`, `technician`, `blue-collar`, `management`, etc. |
|
| 47 |
+
| `marital` | Marital status (categorical). Values: `married`, `single`, `divorced`. |
|
| 48 |
+
| `education` | Education level (categorical). Values: `primary`, `secondary`, `tertiary`, `unknown`. |
|
| 49 |
+
| `default` | Has credit in default? (categorical). Values: `yes`, `no`, `unknown`. |
|
| 50 |
+
| `balance` | Average yearly balance in euros (numeric). |
|
| 51 |
+
| `housing` | Has a housing loan? (categorical). Values: `yes`, `no`, `unknown`. |
|
| 52 |
+
| `loan` | Has a personal loan? (categorical). Values: `yes`, `no`, `unknown`. |
|
| 53 |
+
|
| 54 |
+
### Contact attributes
|
| 55 |
+
|
| 56 |
+
| Column | Description |
|
| 57 |
+
| ---------- | -------------------------------------------------------------------------------------------------------------------------- |
|
| 58 |
+
| `contact` | Communication type (categorical). Values: `cellular`, `telephone`. |
|
| 59 |
+
| `day` | Last contact day of the month (numeric). |
|
| 60 |
+
| `month` | Last contact month of the year (categorical). Values: `jan`, `feb`, `mar`, etc. |
|
| 61 |
+
| `duration` | Last contact duration, in seconds (numeric). |
|
| 62 |
+
|
| 63 |
+
### Campaign Attributes
|
| 64 |
+
|
| 65 |
+
| Column | Description |
|
| 66 |
+
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
| 67 |
+
| `campaign` | Number of contacts performed during this campaign for this client (numeric, includes last contact). |
|
| 68 |
+
| `pdays` | Number of days that passed after the client was last contacted in a previous campaign (-1 means client was not previously contacted). |
|
| 69 |
+
| `previous` | Number of contacts performed before this campaign for this client (numeric). |
|
| 70 |
+
| `poutcome` | Outcome of the previous marketing campaign (categorical). Values: `success`, `failure`, `other`, `unknown`. |
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
""")
|
| 75 |
+
with container2:
|
| 76 |
+
col1, col2 =st.columns([0.4, 0.6])
|
| 77 |
+
with col1:
|
| 78 |
+
st.markdown("""
|
| 79 |
+
# Feature engineering
|
| 80 |
+
|
| 81 |
+
We did some feature engineering to make the data more suitable for modeling.
|
| 82 |
+
as mentioned earlier, we created a datetime variable from the day, month and year (inferred from the order of the data).
|
| 83 |
+
|
| 84 |
+
We also refined and added several new features to help the model better understand customer behavior and campaign performance.
|
| 85 |
+
For instance:
|
| 86 |
+
- We **binned `pdays`** into intervals like βNo contactβ or β0β5 monthsβ to capture how long itβs been since a customer was last reached.
|
| 87 |
+
- We **grouped previous contacts** (`n_previous_contacts`) to see how persistence impacts outcomes: too many calls might lower interest!
|
| 88 |
+
- We added **simple True/False flags** like `had_contact` and `is_single` to make the model pick up behavioral patterns more easily.
|
| 89 |
+
- We created an **βunknown contactβ indicator** to handle cases where the contact type wasnβt recorded, improving data consistency.
|
| 90 |
+
- We converted months into numbers (`month_num`) and inferred a **campaign year** so time-based patterns become clearer.
|
| 91 |
+
- We combined these into a full **datetime column (`date`)** and a **`year_month`** feature for easy trend analysis.
|
| 92 |
+
- Finally, we **capped extreme values** in `balance` and `campaign` to prevent outliers from distorting the model.
|
| 93 |
+
|
| 94 |
+
Together, these transformations made the dataset cleaner, more interpretable, and better aligned with real-world marketing insights.
|
| 95 |
+
|
| 96 |
+
""")
|
| 97 |
+
|
| 98 |
+
with col2:
|
| 99 |
+
st.markdown("""
|
| 100 |
+
### Feature Engineering
|
| 101 |
+
|
| 102 |
+
| Feature Name | Description |
|
| 103 |
+
|-------------------------------|---------------------------------------------------------------------------------------------------|
|
| 104 |
+
| `months_since_previous_contact` | Binned version of `pdays` into intervals (e.g., "No contact", "0 - 5 months", etc.) |
|
| 105 |
+
| `n_previous_contacts` | Binned version of `previous` into categories ("No contact", "1", ..., "More than 6") |
|
| 106 |
+
| `had_contact` | Boolean: True if client had previous contact (`months_since_previous_contact` β "No contact") |
|
| 107 |
+
| `is_single` | Boolean: True if marital status is "single" |
|
| 108 |
+
| `uknown_contact` | Boolean: True if contact type is "unknown" |
|
| 109 |
+
| `month_num` | Numeric month extracted from categorical `month` |
|
| 110 |
+
| `year` | Year inferred from campaign sequence |
|
| 111 |
+
| `date` | Combined datetime column from day, month, and year |
|
| 112 |
+
| `year_moth` | Year and month as datetime for time-based splitting |
|
| 113 |
+
| `balance` (capped) | Capped at 99th percentile to reduce outlier impact |
|
| 114 |
+
| `campaign` (capped) | Capped at 90th percentile for distribution analysis |
|
| 115 |
+
|
| 116 |
+
These features were created to improve model interpretability, handle outliers, and enable time-based splits.
|
| 117 |
+
""")
|
pages/3_π_EDA_and_visualization.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import numpy as np
|
| 4 |
+
import matplotlib.pyplot as plt
|
| 5 |
+
import seaborn as sns
|
| 6 |
+
import altair as alt
|
| 7 |
+
import requests
|
| 8 |
+
import zipfile
|
| 9 |
+
import io
|
| 10 |
+
|
| 11 |
+
# Use session state to load data only once per session
|
| 12 |
+
def load_data():
|
| 13 |
+
url = "https://archive.ics.uci.edu/ml/machine-learning-databases/00222/bank.zip"
|
| 14 |
+
r = requests.get(url)
|
| 15 |
+
z = zipfile.ZipFile(io.BytesIO(r.content))
|
| 16 |
+
df = pd.read_csv(z.open("bank-full.csv"), sep=";")
|
| 17 |
+
return df
|
| 18 |
+
|
| 19 |
+
if "bank_df" not in st.session_state:
|
| 20 |
+
st.session_state["bank_df"] = load_data()
|
| 21 |
+
|
| 22 |
+
df = st.session_state["bank_df"]
|
| 23 |
+
|
| 24 |
+
distribution_variables = ['age', 'balance', 'day', 'duration', 'campaign', 'pdays', 'previous']
|
| 25 |
+
imbalance_variables = ['job', 'marital', 'education', 'default', 'housing', 'loan', 'contact', 'month', 'poutcome']
|
| 26 |
+
|
| 27 |
+
with st.sidebar:
|
| 28 |
+
st.header("Distribution visualizations")
|
| 29 |
+
with st.form("Visualization options"):
|
| 30 |
+
select_variable = st.multiselect("Select variable(s) to visualize", distribution_variables, default=['age','balance'])
|
| 31 |
+
plot_type = st.radio("Plot type", options=["Histogram", "KDE"], index=0)
|
| 32 |
+
submit_button = st.form_submit_button(label="Update")
|
| 33 |
+
st.header("Imbalance plots")
|
| 34 |
+
with st.form("Imbalance options"):
|
| 35 |
+
select_imbalance = st.multiselect("Select variable(s) to visualize", imbalance_variables, default=['job','education'])
|
| 36 |
+
submit_button2 = st.form_submit_button(label="Update")
|
| 37 |
+
|
| 38 |
+
st.set_page_config(page_title="EDA and Visualization", page_icon=":mag:", layout="wide")
|
| 39 |
+
|
| 40 |
+
st.title("Exploratory Data Analysis and Visualization")
|
| 41 |
+
st.markdown("""
|
| 42 |
+
This page allows you to explore the distribution of numerical variables and the imbalance of categorical variables in relation to the target variable `y` (whether the client subscribed to a long term deposit). Use the sidebar to select variables and plot types.
|
| 43 |
+
- **Distribution Plots**: Choose numerical variables to see their distribution (histogram or KDE) segmented by the target variable `y`.
|
| 44 |
+
- **Imbalance Plots**: Choose categorical variables to visualize their class distribution segmented by the target variable `y`.
|
| 45 |
+
|
| 46 |
+
Depeding on your selections, the plots will update accordingly, and our thoughts will be attached after each plot.
|
| 47 |
+
""")
|
| 48 |
+
# Distribution plots (histogram or kde by 'y')
|
| 49 |
+
if submit_button and select_variable:
|
| 50 |
+
st.subheader("Distribution by Target (y)")
|
| 51 |
+
n_cols = 2
|
| 52 |
+
n_vars = len(select_variable)
|
| 53 |
+
n_rows = (n_vars + n_cols - 1) // n_cols # Ceiling division
|
| 54 |
+
|
| 55 |
+
for row in range(n_rows):
|
| 56 |
+
cols = st.columns(n_cols)
|
| 57 |
+
for col_idx in range(n_cols):
|
| 58 |
+
idx = row * n_cols + col_idx
|
| 59 |
+
if idx >= n_vars:
|
| 60 |
+
# If there are fewer plots than grid cells, leave empty
|
| 61 |
+
continue
|
| 62 |
+
var = select_variable[idx]
|
| 63 |
+
col = cols[col_idx]
|
| 64 |
+
if plot_type == "Histogram":
|
| 65 |
+
chart = alt.Chart(df).mark_bar(opacity=0.7).encode(
|
| 66 |
+
x=alt.X(var, bin=alt.Bin(maxbins=30), title=var),
|
| 67 |
+
y=alt.Y('count()', title='Count'),
|
| 68 |
+
color=alt.Color('y', title='Subscribed'),
|
| 69 |
+
tooltip=[var, 'y']
|
| 70 |
+
).properties(
|
| 71 |
+
width=350,
|
| 72 |
+
height=250,
|
| 73 |
+
title=f"{var} histogram by subscription"
|
| 74 |
+
)
|
| 75 |
+
col.altair_chart(chart, use_container_width=True)
|
| 76 |
+
# After plotting each distribution plot:
|
| 77 |
+
if var == "age":
|
| 78 |
+
col.info("Age is right-skewed, most clients are between 30 and 50.")
|
| 79 |
+
elif var == "balance":
|
| 80 |
+
col.info("Balance has a long tail, with most clients having low or negative balances. We will clip it for our model.")
|
| 81 |
+
elif var == "day":
|
| 82 |
+
col.info("Day of month is fairly uniform, with slight peaks around the start and end of the month. There might be weekends affecting this.")
|
| 83 |
+
elif var == "duration":
|
| 84 |
+
col.info("Duration is right-skewed, with many short calls and a few very long ones. We will clip it for our model.")
|
| 85 |
+
elif var == "campaign":
|
| 86 |
+
col.info("Campaign calls are right-skewed, with most clients receiving few calls. We won't clip this for our model, as it might be informative or affect the target variable too much.")
|
| 87 |
+
elif var == "pdays":
|
| 88 |
+
col.info("-1 indicates no previous contact, which is very common in the data. Other values are right-skewed, with many clients not contacted for a long time. We've decided to bin them in the model.")
|
| 89 |
+
elif var == "previous":
|
| 90 |
+
col.info("Previous contacts are right-skewed, with most clients having few previous contacts. We will bin this for our model.")
|
| 91 |
+
else: # KDE
|
| 92 |
+
fig, ax = plt.subplots(figsize=(4, 3))
|
| 93 |
+
for label, color in zip(['yes', 'no'], ['green', 'red']):
|
| 94 |
+
subset = df[df['y'] == label][var]
|
| 95 |
+
sns.kdeplot(subset, label=f"y = {label}", color=color, fill=True, alpha=0.3, ax=ax)
|
| 96 |
+
ax.set_title(f"{var} KDE by subscription")
|
| 97 |
+
ax.set_xlabel(var)
|
| 98 |
+
ax.set_ylabel("Density")
|
| 99 |
+
ax.legend()
|
| 100 |
+
col.pyplot(fig)
|
| 101 |
+
plt.close(fig)
|
| 102 |
+
# After plotting each distribution plot:
|
| 103 |
+
if var == "age":
|
| 104 |
+
col.info("The density shows a peak around 35-40 years, with a slight difference between subscribers and non-subscribers. Notably, subscribers tend to be slightly older.")
|
| 105 |
+
elif var == "balance":
|
| 106 |
+
col.info("Subscribers tend to have higher balances, as seen by the green curve shift.")
|
| 107 |
+
elif var == "day":
|
| 108 |
+
col.info("The density is fairly uniform, with slight peaks, maybe around weekend? Better at the start of month?. Subscribers show a slightly different pattern.")
|
| 109 |
+
elif var == "duration":
|
| 110 |
+
col.info("Subscribers tend to have longer call durations, as seen by the green curve shift. However, there might be model leakage, as we can't use call duration to predict subscription, because we dont know for how long the call is going to go.")
|
| 111 |
+
elif var == "campaign":
|
| 112 |
+
col.info("The density is right-skewed, with most clients having few campaign calls.")
|
| 113 |
+
elif var == "pdays":
|
| 114 |
+
col.info("The density shows a peak at -1 (no previous contact). Subscribers tend to have been contacted more recently. We've binned this for our model.")
|
| 115 |
+
elif var == "previous":
|
| 116 |
+
col.info("The density is right-skewed, with most clients having few previous contacts. We've binned this for our model.")
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# Helper to cache imbalance proportions per variable in session state
|
| 121 |
+
def get_prop_df(var):
|
| 122 |
+
cache_key = f"prop_df_{var}"
|
| 123 |
+
if cache_key not in st.session_state:
|
| 124 |
+
prop_df = (
|
| 125 |
+
df.groupby(var)['y']
|
| 126 |
+
.value_counts(normalize=True)
|
| 127 |
+
.rename('proportion')
|
| 128 |
+
.reset_index()
|
| 129 |
+
)
|
| 130 |
+
st.session_state[cache_key] = prop_df
|
| 131 |
+
return st.session_state[cache_key]
|
| 132 |
+
|
| 133 |
+
# Imbalance plots (stacked bar by 'y')
|
| 134 |
+
if submit_button2 and select_imbalance:
|
| 135 |
+
st.subheader("Imbalance by Target (y)")
|
| 136 |
+
for var in select_imbalance:
|
| 137 |
+
prop_df = get_prop_df(var)
|
| 138 |
+
df_tooltip = pd.merge(df, prop_df, on=[var, 'y'], how='left')
|
| 139 |
+
|
| 140 |
+
# Custom month order if plotting 'month'
|
| 141 |
+
if var == "month":
|
| 142 |
+
month_order = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
|
| 143 |
+
x_axis = alt.X(var, title=var, sort=month_order)
|
| 144 |
+
else:
|
| 145 |
+
x_axis = alt.X(var, title=var)
|
| 146 |
+
|
| 147 |
+
chart = alt.Chart(df_tooltip).mark_bar().encode(
|
| 148 |
+
x=x_axis,
|
| 149 |
+
y=alt.Y('count()', stack='normalize', title='Proportion'),
|
| 150 |
+
color=alt.Color('y', title='Subscribed', sort=['no', 'yes']),
|
| 151 |
+
order=alt.Order('y', sort='ascending'),
|
| 152 |
+
tooltip=[
|
| 153 |
+
var,
|
| 154 |
+
'y',
|
| 155 |
+
alt.Tooltip('proportion:Q', title='Proportion', format='.2%'),
|
| 156 |
+
alt.Tooltip('count():Q', title='Count')
|
| 157 |
+
]
|
| 158 |
+
).properties(
|
| 159 |
+
width=350,
|
| 160 |
+
height=650,
|
| 161 |
+
title=f"{var} imbalance by subscription"
|
| 162 |
+
)
|
| 163 |
+
st.altair_chart(chart, use_container_width=True)
|
| 164 |
+
# After plotting each imbalance plot:
|
| 165 |
+
if var == "job":
|
| 166 |
+
st.info("Certain jobs (e.g., management, retired and maybe suprisingly student) have higher subscription rates, but not by much.")
|
| 167 |
+
elif var == "education":
|
| 168 |
+
st.info("Higher education levels seem correlated with higher subscription rates.")
|
| 169 |
+
elif var == "marital":
|
| 170 |
+
st.info("Pretty uniform subscription rates across marital statuses, with slight variations.")
|
| 171 |
+
elif var == "default":
|
| 172 |
+
st.info("Clients with credit default have a little lower subscription rate.")
|
| 173 |
+
elif var == "housing":
|
| 174 |
+
st.info("Clients with housing loans have a little lower subscription rate.")
|
| 175 |
+
elif var == "loan":
|
| 176 |
+
st.info("Clients with personal loans have a little lower subscription rate.")
|
| 177 |
+
elif var == "contact":
|
| 178 |
+
st.info("Contact method affects subscription rates, with cellular contacts having a higher rate.")
|
| 179 |
+
elif var == "month":
|
| 180 |
+
st.info("Subscription rates vary some by month, with peaks in certain months (e.g., mar. sep. oct. and dec.).")
|
| 181 |
+
elif var == "poutcome":
|
| 182 |
+
st.info("Previous campaign outcomes strongly influence subscription rates, with 'success' leading to much higher rates.")
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# Thoughts based on the visualizations
|
| 186 |
+
|
| 187 |
+
|
pages/4_π_Model_interpretation.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import shap
|
| 3 |
+
import matplotlib.pyplot as plt
|
| 4 |
+
import numpy as np
|
| 5 |
+
import joblib
|
| 6 |
+
import pandas as pd
|
| 7 |
+
from sklearn.metrics import roc_curve, roc_auc_score, precision_recall_curve, auc
|
| 8 |
+
|
| 9 |
+
st.set_page_config(page_title="Model Analysis Dashboard", layout="wide")
|
| 10 |
+
st.title("Model Analysis Dashboard")
|
| 11 |
+
|
| 12 |
+
# --- Load model and test data ---
|
| 13 |
+
@st.cache_data
|
| 14 |
+
def load_model_and_data():
|
| 15 |
+
model = joblib.load("model_1mvp.pkl")
|
| 16 |
+
df_test = pd.read_csv("test_data.csv")
|
| 17 |
+
return model, df_test
|
| 18 |
+
|
| 19 |
+
model, df_test = load_model_and_data()
|
| 20 |
+
|
| 21 |
+
target = "y"
|
| 22 |
+
X_test = df_test.drop(columns=[target])
|
| 23 |
+
y_test = df_test[target]
|
| 24 |
+
|
| 25 |
+
preprocessor = model.named_steps["preprocessor"]
|
| 26 |
+
feature_names = preprocessor.get_feature_names_out()
|
| 27 |
+
X_test_transformed = preprocessor.transform(X_test)
|
| 28 |
+
|
| 29 |
+
# --- SHAP Explainer (precompute for efficiency) ---
|
| 30 |
+
explainer = shap.LinearExplainer(model.named_steps["classifier"], X_test_transformed, feature_names=feature_names)
|
| 31 |
+
shap_values = explainer.shap_values(X_test_transformed)
|
| 32 |
+
expected_value = explainer.expected_value
|
| 33 |
+
|
| 34 |
+
# --- Sidebar: Plot selection and controls ---
|
| 35 |
+
with st.sidebar.form("plot_selector"):
|
| 36 |
+
st.markdown("## Select plots to display")
|
| 37 |
+
show_coeff = st.checkbox("Logistic Regression Coefficients", value=True)
|
| 38 |
+
show_shap_global = st.checkbox("SHAP Global (summary plot)", value=True)
|
| 39 |
+
show_shap_local = st.checkbox("SHAP Local (waterfall plot)", value=False)
|
| 40 |
+
show_roc = st.checkbox("ROC/PR Curves", value=True)
|
| 41 |
+
top_n = st.slider("Number of top features for LogReg coeffecients", 5, 30, 15)
|
| 42 |
+
local_idx = st.number_input("Local SHAP sample index", min_value=0, max_value=len(X_test)-1, value=0)
|
| 43 |
+
submitted = st.form_submit_button("Update plots")
|
| 44 |
+
|
| 45 |
+
# --- Logistic Regression Coefficient Plot ---
|
| 46 |
+
if show_coeff and submitted:
|
| 47 |
+
st.header("Logistic Regression Coefficients")
|
| 48 |
+
logreg_model = model.named_steps["classifier"]
|
| 49 |
+
coefficients = logreg_model.coef_[0]
|
| 50 |
+
importance = pd.DataFrame({
|
| 51 |
+
"feature": feature_names,
|
| 52 |
+
"coefficient": coefficients
|
| 53 |
+
}).sort_values(by="coefficient", key=abs, ascending=False)
|
| 54 |
+
fig, ax = plt.subplots(figsize=(8, 6))
|
| 55 |
+
importance.head(top_n).set_index("feature")["coefficient"].plot(kind="barh", ax=ax, color="#4C72B0")
|
| 56 |
+
ax.set_title("Logistic Regression Feature Importance (Coefficients)")
|
| 57 |
+
ax.set_xlabel("Coefficient Value")
|
| 58 |
+
ax.set_ylabel("Feature")
|
| 59 |
+
st.pyplot(fig)
|
| 60 |
+
st.dataframe(importance.head(top_n).style.format({"coefficient": "{:.3f}"}))
|
| 61 |
+
|
| 62 |
+
# --- SHAP Analysis ---
|
| 63 |
+
if (show_shap_global or show_shap_local) and submitted:
|
| 64 |
+
st.header("SHAP Analysis")
|
| 65 |
+
if show_shap_global:
|
| 66 |
+
st.subheader("Global Feature Importance (SHAP Summary Plot)")
|
| 67 |
+
fig, ax = plt.subplots(figsize=(10, 6))
|
| 68 |
+
shap.summary_plot(shap_values, X_test_transformed, feature_names=feature_names, show=False)
|
| 69 |
+
st.pyplot(fig)
|
| 70 |
+
if show_shap_local:
|
| 71 |
+
st.subheader("Local Explanation (SHAP Waterfall Plot)")
|
| 72 |
+
fig2, ax2 = plt.subplots(figsize=(10, 6))
|
| 73 |
+
shap.plots.waterfall(
|
| 74 |
+
shap.Explanation(
|
| 75 |
+
values=shap_values[local_idx],
|
| 76 |
+
base_values=expected_value,
|
| 77 |
+
data=X_test_transformed[local_idx],
|
| 78 |
+
feature_names=feature_names
|
| 79 |
+
),
|
| 80 |
+
max_display=15,
|
| 81 |
+
show=False
|
| 82 |
+
)
|
| 83 |
+
st.pyplot(fig2)
|
| 84 |
+
|
| 85 |
+
# --- ROC and PR Curves ---
|
| 86 |
+
if show_roc and submitted:
|
| 87 |
+
st.header("Model Performance Metrics (ROC / PR Curves)")
|
| 88 |
+
y_pred_proba = model.predict_proba(X_test)[:, 1]
|
| 89 |
+
roc_auc = roc_auc_score(y_test, y_pred_proba)
|
| 90 |
+
fpr, tpr, _ = roc_curve(y_test, y_pred_proba)
|
| 91 |
+
precision, recall, _ = precision_recall_curve(y_test, y_pred_proba)
|
| 92 |
+
pr_auc = auc(recall, precision)
|
| 93 |
+
|
| 94 |
+
col1, col2 = st.columns(2)
|
| 95 |
+
with col1:
|
| 96 |
+
st.metric("ROC AUC", f"{roc_auc:.3f}")
|
| 97 |
+
with col2:
|
| 98 |
+
st.metric("PR AUC", f"{pr_auc:.3f}")
|
| 99 |
+
|
| 100 |
+
fig1, ax1 = plt.subplots(figsize=(5, 5))
|
| 101 |
+
ax1.plot(fpr, tpr, color="darkorange", lw=2, label=f"ROC curve (AUC = {roc_auc:.3f})")
|
| 102 |
+
ax1.plot([0, 1], [0, 1], color="navy", lw=2, linestyle="--", label="Random Guess")
|
| 103 |
+
ax1.set_xlabel("False Positive Rate")
|
| 104 |
+
ax1.set_ylabel("True Positive Rate")
|
| 105 |
+
ax1.set_title("ROC Curve")
|
| 106 |
+
ax1.legend()
|
| 107 |
+
st.pyplot(fig1)
|
| 108 |
+
|
| 109 |
+
fig2, ax2 = plt.subplots(figsize=(5, 5))
|
| 110 |
+
ax2.plot(recall, precision, color="#C44E52")
|
| 111 |
+
ax2.set_xlabel("Recall")
|
| 112 |
+
ax2.set_ylabel("Precision")
|
| 113 |
+
ax2.set_title("Precision-Recall Curve")
|
| 114 |
+
st.pyplot(fig2)
|
pages/5_π²_Magenment_deck.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import numpy as np
|
| 4 |
+
import joblib
|
| 5 |
+
|
| 6 |
+
st.set_page_config(page_title="π² Management Deck: Cost Analysis", layout="wide")
|
| 7 |
+
st.title("π² Management Deck: Cost Analysis of Model Decisions")
|
| 8 |
+
|
| 9 |
+
# --- Load model and test data ---
|
| 10 |
+
@st.cache_data
|
| 11 |
+
def load_model_and_data():
|
| 12 |
+
model = joblib.load("model_1mvp.pkl")
|
| 13 |
+
df_test = pd.read_csv("test_data.csv")
|
| 14 |
+
return model, df_test
|
| 15 |
+
|
| 16 |
+
model, df_test = load_model_and_data()
|
| 17 |
+
target = "y"
|
| 18 |
+
X_test = df_test.drop(columns=[target])
|
| 19 |
+
y_test = df_test[target]
|
| 20 |
+
|
| 21 |
+
# --- Sidebar: Cost selection ---
|
| 22 |
+
with st.sidebar.form("cost_selector"):
|
| 23 |
+
st.markdown("## Set Cost Parameters")
|
| 24 |
+
cost_fp = st.number_input("Cost of False Positive (FP)", min_value=0, value=5, step=1)
|
| 25 |
+
cost_fn = st.number_input("Cost of False Negative (FN)", min_value=0, value=30, step=1)
|
| 26 |
+
submitted = st.form_submit_button("Update Cost Analysis")
|
| 27 |
+
|
| 28 |
+
if submitted:
|
| 29 |
+
st.subheader("Cost Analysis Based on Model Predictions")
|
| 30 |
+
|
| 31 |
+
# Predict probabilities and classes
|
| 32 |
+
y_pred_proba = model.predict_proba(X_test)[:, 1]
|
| 33 |
+
threshold = 0.5
|
| 34 |
+
y_pred = (y_pred_proba >= threshold).astype(int)
|
| 35 |
+
|
| 36 |
+
# Confusion matrix components
|
| 37 |
+
FP = np.sum((y_pred == 1) & (y_test == 0))
|
| 38 |
+
FN = np.sum((y_pred == 0) & (y_test == 1))
|
| 39 |
+
TP = np.sum((y_pred == 1) & (y_test == 1))
|
| 40 |
+
TN = np.sum((y_pred == 0) & (y_test == 0))
|
| 41 |
+
|
| 42 |
+
total_cost = FP * cost_fp + FN * cost_fn
|
| 43 |
+
|
| 44 |
+
st.markdown(f"""
|
| 45 |
+
**Threshold:** {threshold:.2f}
|
| 46 |
+
- **False Positives (FP):** {FP} Γ {cost_fp} = {FP * cost_fp}
|
| 47 |
+
- **False Negatives (FN):** {FN} Γ {cost_fn} = {FN * cost_fn}
|
| 48 |
+
- **True Positives (TP):** {TP}
|
| 49 |
+
- **True Negatives (TN):** {TN}
|
| 50 |
+
---
|
| 51 |
+
## **Total Cost: {total_cost}**
|
| 52 |
+
""")
|
| 53 |
+
|
| 54 |
+
# Optional: Show cost as a function of threshold
|
| 55 |
+
st.subheader("Cost vs. Threshold")
|
| 56 |
+
thresholds = np.linspace(0, 1, 120)
|
| 57 |
+
costs = []
|
| 58 |
+
for t in thresholds:
|
| 59 |
+
y_pred_t = (y_pred_proba >= t).astype(int)
|
| 60 |
+
FP_t = np.sum((y_pred_t == 1) & (y_test == 0))
|
| 61 |
+
FN_t = np.sum((y_pred_t == 0) & (y_test == 1))
|
| 62 |
+
costs.append(FP_t * cost_fp + FN_t * cost_fn)
|
| 63 |
+
import matplotlib.pyplot as plt
|
| 64 |
+
fig, ax = plt.subplots(figsize=(8, 4))
|
| 65 |
+
ax.plot(thresholds, costs, label="Total Cost")
|
| 66 |
+
ax.axvline(threshold, color="red", linestyle="--", label=f"Current threshold = {threshold:.2f}")
|
| 67 |
+
ax.set_xlabel("Threshold")
|
| 68 |
+
ax.set_ylabel("Total Cost")
|
| 69 |
+
ax.set_title("Total Cost vs. Classification Threshold")
|
| 70 |
+
ax.legend()
|
| 71 |
+
st.pyplot(fig)
|
| 72 |
+
|
| 73 |
+
st.caption("You can adjust the costs in the sidebar to see their impact on the total cost and optimal threshold.")
|
pages/6_π_Callcenter_dashboard.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
from streamlit_extras.let_it_rain import rain
|
| 3 |
+
import requests
|
| 4 |
+
import random
|
| 5 |
+
import pandas as pd
|
| 6 |
+
import datetime
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
st.title("π Callcenter Dashboard")
|
| 10 |
+
|
| 11 |
+
with st.expander("βΉοΈ - About this dashboard", expanded=False):
|
| 12 |
+
st.markdown(
|
| 13 |
+
"""
|
| 14 |
+
This dashboard simulates a call center environment where agents can manage a queue of customers to upsell a long term deposit bank product.
|
| 15 |
+
In the original paper that came with the dataset, they mention that there was inbound calls too, but it's not present in the dataset.
|
| 16 |
+
The dashboard fetches customer data from an API(NocoDB with test and synthetic data), displays customer information, and uses a machine learning model to predict the likelihood of a successful upsell.
|
| 17 |
+
|
| 18 |
+
**How to use the dashboard:**
|
| 19 |
+
1. Set the queue size and upsell bonus in the sidebar. The bonus is simply a multiplier for the potential earnings from successful upsells.
|
| 20 |
+
2. View the current queue of customers and their details.
|
| 21 |
+
3. For each customer, see the model's predicted probability of subscription.
|
| 22 |
+
4. After each call, indicate whether the upsell was successful and submit the result.
|
| 23 |
+
5. Track your total bonus based on successful upsells.
|
| 24 |
+
|
| 25 |
+
**TIP** see what happens when the queue is empty π
|
| 26 |
+
"""
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
# --- Sidebar: Set queue size and bonus, and show model probability ---
|
| 30 |
+
with st.sidebar:
|
| 31 |
+
st.header("Queue Settings")
|
| 32 |
+
queue_size = st.number_input("Queue size", min_value=1, max_value=50, value=10, step=1)
|
| 33 |
+
bonus = st.number_input("Upsell Bonus (currency/unit)", min_value=1.0, value=10.0, step=1.0)
|
| 34 |
+
if st.button("Reset Queue"):
|
| 35 |
+
st.session_state.queue = None # Force re-fetch
|
| 36 |
+
st.session_state.total_bonus = 0.0
|
| 37 |
+
# Placeholder for model probability
|
| 38 |
+
model_prob_placeholder = st.empty()
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# --- Cached data fetch ---
|
| 43 |
+
@st.cache_data(show_spinner=False)
|
| 44 |
+
def fetch_customers(limit):
|
| 45 |
+
API_DATA_URL = "https://dun3co-sdc-nocodb.hf.space/api/v2/tables/m39a8axnn3980w9/records"
|
| 46 |
+
API_DATA_TOKEN = st.secrets["NOCODB_TOKEN"]
|
| 47 |
+
HEADERS = {"xc-token": API_DATA_TOKEN}
|
| 48 |
+
params = {"offset": 0, "limit": limit, "viewId": "vwjuv5jnaet9npuu"}
|
| 49 |
+
res = requests.get(API_DATA_URL, headers=HEADERS, params=params)
|
| 50 |
+
res.raise_for_status()
|
| 51 |
+
return res.json()["list"]
|
| 52 |
+
|
| 53 |
+
# --- Initialize or reset queue and bonus ---
|
| 54 |
+
if "queue" not in st.session_state or st.session_state.queue is None:
|
| 55 |
+
records = fetch_customers(queue_size)
|
| 56 |
+
st.session_state.queue = random.sample(records, len(records))
|
| 57 |
+
if "total_bonus" not in st.session_state:
|
| 58 |
+
st.session_state.total_bonus = 0.0
|
| 59 |
+
|
| 60 |
+
# --- Calculate maximum potential bonus for the remaining queue ---
|
| 61 |
+
def get_max_potential_bonus(queue, bonus):
|
| 62 |
+
if not queue:
|
| 63 |
+
return 0.0, []
|
| 64 |
+
API_MODEL_URL = "https://dun3co-marketing-lr-prediction.hf.space/predict"
|
| 65 |
+
inputs = []
|
| 66 |
+
for row in queue:
|
| 67 |
+
inputs.append({
|
| 68 |
+
"age": int(row["age"]),
|
| 69 |
+
"balance": float(row["balance"]),
|
| 70 |
+
"day": int(row["day"]),
|
| 71 |
+
"campaign": int(row["campaign"]),
|
| 72 |
+
"job": str(row["job"]),
|
| 73 |
+
"education": str(row["education"]),
|
| 74 |
+
"default": str(row["default"]),
|
| 75 |
+
"housing": str(row["housing"]),
|
| 76 |
+
"loan": str(row["loan"]),
|
| 77 |
+
"months_since_previous_contact": str(row["months_since_previous_contact"]),
|
| 78 |
+
"n_previous_contacts": str(row["n_previous_contacts"]),
|
| 79 |
+
"poutcome": str(row["poutcome"]),
|
| 80 |
+
"had_contact": bool(row["had_contact"]),
|
| 81 |
+
"is_single": bool(row["is_single"]),
|
| 82 |
+
"uknown_contact": bool(row["uknown_contact"]),
|
| 83 |
+
})
|
| 84 |
+
try:
|
| 85 |
+
response = requests.post(API_MODEL_URL, json={"data": inputs})
|
| 86 |
+
response.raise_for_status()
|
| 87 |
+
probabilities = response.json()["probabilities"]
|
| 88 |
+
max_bonus = sum((1 - p) * bonus for p in probabilities)
|
| 89 |
+
return max_bonus, probabilities
|
| 90 |
+
except Exception:
|
| 91 |
+
return None, None
|
| 92 |
+
|
| 93 |
+
# --- 3. Show queue visually and bonus info ---
|
| 94 |
+
#st.subheader("Queue")
|
| 95 |
+
|
| 96 |
+
# Layout: queue info (left), bonus info (center), (right column left empty for centering)
|
| 97 |
+
queue_col, bonus_col, empty_col = st.columns([2, 1.2, 0.8])
|
| 98 |
+
|
| 99 |
+
with queue_col:
|
| 100 |
+
st.subheader("Queue")
|
| 101 |
+
for i, row in enumerate(st.session_state.queue):
|
| 102 |
+
st.write(f"Position {i+1}: {row['job']} ({row['age']} yrs, {row['education']})")
|
| 103 |
+
|
| 104 |
+
# Calculate max potential bonus and get probabilities for queue
|
| 105 |
+
max_potential_bonus, queue_probabilities = get_max_potential_bonus(st.session_state.queue, bonus)
|
| 106 |
+
|
| 107 |
+
# --- 4. Simulate next call ---
|
| 108 |
+
if st.session_state.queue:
|
| 109 |
+
st.subheader("Active Call")
|
| 110 |
+
active_row = st.session_state.queue[0]
|
| 111 |
+
|
| 112 |
+
# Use current day of month if possible, fallback to API day
|
| 113 |
+
today_day = datetime.datetime.now().day
|
| 114 |
+
try:
|
| 115 |
+
day_value = int(today_day)
|
| 116 |
+
except Exception:
|
| 117 |
+
day_value = int(active_row["day"])
|
| 118 |
+
|
| 119 |
+
# Prepare model input for active call
|
| 120 |
+
input_row = {
|
| 121 |
+
"age": int(active_row["age"]),
|
| 122 |
+
"balance": float(active_row["balance"]),
|
| 123 |
+
"day": day_value,
|
| 124 |
+
"campaign": int(active_row["campaign"]),
|
| 125 |
+
"job": str(active_row["job"]),
|
| 126 |
+
"education": str(active_row["education"]),
|
| 127 |
+
"default": str(active_row["default"]),
|
| 128 |
+
"housing": str(active_row["housing"]),
|
| 129 |
+
"loan": str(active_row["loan"]),
|
| 130 |
+
"months_since_previous_contact": str(active_row["months_since_previous_contact"]),
|
| 131 |
+
"n_previous_contacts": str(active_row["n_previous_contacts"]),
|
| 132 |
+
"poutcome": str(active_row["poutcome"]),
|
| 133 |
+
"had_contact": bool(active_row["had_contact"]),
|
| 134 |
+
"is_single": bool(active_row["is_single"]),
|
| 135 |
+
"uknown_contact": bool(active_row["uknown_contact"]),
|
| 136 |
+
}
|
| 137 |
+
payload = {"data": [input_row]}
|
| 138 |
+
|
| 139 |
+
# --- 5. Get model prediction for active call ---
|
| 140 |
+
API_MODEL_URL = "https://dun3co-marketing-lr-prediction.hf.space/predict"
|
| 141 |
+
try:
|
| 142 |
+
response = requests.post(API_MODEL_URL, json=payload)
|
| 143 |
+
response.raise_for_status()
|
| 144 |
+
result = response.json()
|
| 145 |
+
probability = result["probabilities"][0]
|
| 146 |
+
# Show in sidebar
|
| 147 |
+
model_prob_placeholder.metric("Model Probability (Subscribe)", f"{probability:.2%}")
|
| 148 |
+
except Exception as e:
|
| 149 |
+
st.error(f"Model API call failed: {e}")
|
| 150 |
+
probability = None
|
| 151 |
+
model_prob_placeholder.metric("Model Probability (Subscribe)", "N/A")
|
| 152 |
+
|
| 153 |
+
# --- Customer info as tiles ---
|
| 154 |
+
st.write("### Customer Information")
|
| 155 |
+
keys = [k for k in active_row.keys() if k != "y"] #Dropping the target variable "y"
|
| 156 |
+
values = [active_row[k] for k in keys] #Dropping the target variable "y"
|
| 157 |
+
n_cols = 4
|
| 158 |
+
cols = st.columns(n_cols)
|
| 159 |
+
for i, key in enumerate(keys):
|
| 160 |
+
col = cols[i % n_cols]
|
| 161 |
+
with col:
|
| 162 |
+
# Show the current day_value for the "day" field
|
| 163 |
+
display_value = day_value if key == "day" else values[i]
|
| 164 |
+
st.markdown(
|
| 165 |
+
f"""
|
| 166 |
+
<div style="
|
| 167 |
+
border: 2px solid #e6e6e6;
|
| 168 |
+
border-radius: 16px;
|
| 169 |
+
padding: 18px 10px 14px 10px;
|
| 170 |
+
margin-bottom: 1em;
|
| 171 |
+
background: linear-gradient(135deg, #f9f9f9 80%, #eaf6ff 100%);
|
| 172 |
+
box-shadow: 0 2px 8px 0 rgba(0,0,0,0.04);
|
| 173 |
+
min-height: 80px;
|
| 174 |
+
text-align: center;
|
| 175 |
+
">
|
| 176 |
+
<div style="font-size: 1.05em; font-weight: 600; color: #2c3e50; margin-bottom: 0.3em;">
|
| 177 |
+
{key.replace('_', ' ').capitalize()}
|
| 178 |
+
</div>
|
| 179 |
+
<div style="font-size: 1.15em; color: #0074d9;">
|
| 180 |
+
{display_value}
|
| 181 |
+
</div>
|
| 182 |
+
</div>
|
| 183 |
+
""",
|
| 184 |
+
unsafe_allow_html=True,
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
# --- Bonus info and worker action column ---
|
| 188 |
+
with bonus_col:
|
| 189 |
+
st.markdown(
|
| 190 |
+
"""
|
| 191 |
+
<div style="border:2px solid #e6e6e6; border-radius:14px; padding:18px 14px; background:#f8fbff; margin-bottom:1em;">
|
| 192 |
+
<div style="font-size:1.2em; font-weight:700; margin-bottom:1em;">Bonus KPI's</div>
|
| 193 |
+
<div style="font-size:1.1em; margin-bottom:0.7em;">
|
| 194 |
+
<b>Current Bonus:</b> <span style="color:#0074d9;">{current_bonus}</span>
|
| 195 |
+
</div>
|
| 196 |
+
<div style="font-size:1.1em; margin-bottom:0.7em;">
|
| 197 |
+
<b>Current Call Bonus:</b> <span style="color:#28a745;">{current_call_bonus}</span>
|
| 198 |
+
</div>
|
| 199 |
+
<div style="font-size:1.1em;">
|
| 200 |
+
<b>Max Potential Bonus:</b> <span style="color:#ff851b;">{max_potential_bonus}</span>
|
| 201 |
+
</div>
|
| 202 |
+
</div>
|
| 203 |
+
""".format(
|
| 204 |
+
current_bonus=f"{st.session_state.total_bonus:.2f}",
|
| 205 |
+
current_call_bonus=f"{(1 - probability) * bonus:.2f}" if probability is not None else "N/A",
|
| 206 |
+
max_potential_bonus=f"{max_potential_bonus:.2f}" if max_potential_bonus is not None else "N/A"
|
| 207 |
+
),
|
| 208 |
+
unsafe_allow_html=True,
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
# Plain Streamlit widgets for worker action (no custom styling)
|
| 212 |
+
st.subheader("Callcenter Worker Action")
|
| 213 |
+
upsell = st.radio("Did you upsell?", options=["Yes", "No"], key="upsell_radio", horizontal=True)
|
| 214 |
+
submit = st.button("Submit", disabled=not st.session_state.queue, key="upsell_submit")
|
| 215 |
+
|
| 216 |
+
if submit:
|
| 217 |
+
if upsell == "Yes" and probability is not None:
|
| 218 |
+
st.session_state.total_bonus += (1 - probability) * bonus
|
| 219 |
+
st.session_state.queue.pop(0)
|
| 220 |
+
st.rerun()
|
| 221 |
+
|
| 222 |
+
else:
|
| 223 |
+
rain(emoji="πΈ", font_size=54, falling_speed=5, animation_length="infinite")
|
| 224 |
+
st.success("Queue is empty! All calls handled.")
|
| 225 |
+
st.markdown(
|
| 226 |
+
f"""
|
| 227 |
+
<div style="border:2px solid #e6e6e6; border-radius:14px; padding:18px 14px; background:#f8fbff; margin-bottom:1em;">
|
| 228 |
+
<div style="font-size:1.2em; font-weight:700; margin-bottom:1em;">Total Bonus Earned</div>
|
| 229 |
+
<div style="font-size:2em; color:#0074d9; text-align:center;">
|
| 230 |
+
{st.session_state.total_bonus:.2f}
|
| 231 |
+
</div>
|
| 232 |
+
</div>
|
| 233 |
+
""",
|
| 234 |
+
unsafe_allow_html=True,
|
| 235 |
+
)
|
requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
scikit-learn==1.7.2
|
| 2 |
+
joblib==1.5.2
|
| 3 |
+
numpy==2.3.1
|
| 4 |
+
pandas==2.3.2
|
| 5 |
+
matplotlib
|
| 6 |
+
seaborn
|
| 7 |
+
shap
|
| 8 |
+
altair
|
| 9 |
+
requests
|
| 10 |
+
xgboost
|
| 11 |
+
optuna
|
| 12 |
+
plotly
|