Predictive Analytics for Transfusion Need

Setup

This notebook connects to a local MySQL database (BloodMatching) containing patient, vitals, and lab tables. Credentials are loaded from a .env file (not committed to version control — see .gitignore) rather than hardcoded, so this notebook can be shared publicly without exposing database access.

To run this notebook yourself: create a .env file in this directory with DB_HOST, DB_USER, DB_PASSWORD, and DB_NAME.

Project Overview

A small-data ML pipeline predicting blood transfusion need from routine patient vitals (heart rate, respiratory rate, oxygen saturation, blood pressure, sex) — built on a 206-row MIMIC-style demo dataset (14 positive cases)

Pipeline: SQL ingestion → data cleaning & aggregation → leakage-safe feature selection → SMOTE-balanced cross-validation (Random Forest vs. Logistic Regression) → threshold & calibration analysis → client-side model deployment.

Dataset

The Medical Information Mart for Intensive Care (MIMIC)-IV database is comprised of deidentified electronic health records for patients admitted to the Beth Israel Deaconess Medical Center https://physionet.org/content/mimic-iv-demo/2.2/

Attribute Value
Total rows (patient stays) 206
Positive cases (needs_transfusion = 1) 14 (~6.8%)
Negative cases 192 (~93.2%)
Features used in final model 6
Source tables patients, vital, lab_result
Database MySQL (BloodMatching)

Features(6, after leakage removal)

Feature Description Type
Gender Patient sex, label-encoded Categorical
heart_rate_mean Mean heart rate across the stay Continuous
resprate_mean Mean respiratory rate across the stay Continuous
o2sat_mean Mean oxygen saturation across the stay Continuous
sbp_mean Mean systolic blood pressure across the stay Continuous
dbp_mean Mean diastolic blood pressure across the stay Continuous

Target

needs_transfusion is a rule-derived synthetic label (hemoglobin < 7.5 OR (sbp_min < 90 AND heart_rate_max > 100)), not a real clinical transfusion-order event.

hemoglobin, sbp_min, and heart_rate_max are excluded from the feature set since they directly define the label.

Methodology

  • Ingestion — patients, vitals, and lab data loaded into a local MySQL database via SQLAlchemy, credentials handled through environment variables.

  • Cleaning & aggregation — vitals aggregated from repeated per-reading rows to one row per stay (fixing a many-to-many join bug in the process); numeric coercion, sentinel-value handling, and median imputation for missing values.

  • Leakage-safe feature selection — label-defining columns and everything derived from them excluded from X, after two separate rounds of leakage were traced and fixed.

  • Class imbalance handling — SMOTE applied inside each cross-validation fold via an imblearn.Pipeline, avoiding synthetic-sample leakage across folds.

  • Model comparison — Random Forest vs. Logistic Regression, both evaluated on identical stratified 5-fold splits, reporting mean ROC-AUC ± standard deviation rather than a single score.

  • Threshold & calibration — out-of-fold probabilities used to select an operating threshold via precision-recall curve (favoring recall, since missing a transfusion need is costlier than a false alarm), with a calibration curve checking whether predicted probabilities are trustworthy.

  • Deployment — final model refit on the full dataset, exported to JavaScript via m2cgen, running entirely client-side in the interactive demo.

Key Findings

# Finding Impact
1 Label leakage found and fixed twice — once as a pandas formula, once baked into the SQL view itself Both produced a meaningless perfect ROC-AUC of 1.0 until corrected
2 Database join-explosion bug: two one-to-many tables joined without a shared row key Inflated 222 patients into 2M+ rows before being caught
3 Reproducibility bug: unseeded random hemoglobin generator Dataset labels silently changed on every re-run until fixed
4 Logistic Regression matched/outperformed Random Forest in CV Legitimate small-data finding, not a failed experiment
5 Real model shows non-monotonic behavior on heart rate Concrete evidence of small-sample overfitting, disclosed openly in the demo
6 Final CV metric [fill in mean ROC-AUC ± std once your reproducible run is locked]
In [1]:
import sys
import pandas as pd
from sqlalchemy import create_engine
import numpy as np
import mysql.connector as db
import os
import matplotlib.pyplot as plt
import seaborn as sns
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from dotenv import load_dotenv
from sklearn.preprocessing import LabelEncoder 
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, confusion_matrix, roc_auc_score , precision_recall_curve
from sklearn.model_selection import StratifiedKFold, cross_val_score , cross_val_predict
from sklearn.calibration import calibration_curve
from imblearn.over_sampling import SMOTE
from imblearn.pipeline import Pipeline as ImbPipeline
import m2cgen as m2c
In [2]:
print(os.getcwd())
print(sys.executable)
/Users/samikshatripathy/Data Science/blood Transfusion
/opt/miniconda3/envs/my-conda-env/bin/python
In [3]:
found = load_dotenv()
print('Loaded:',found)
Loaded: True
In [4]:
import os
from dotenv import load_dotenv

load_dotenv()  # reads the .env file into environment variables

con = db.connect(
    host=os.getenv("DB_HOST"),
    user=os.getenv("DB_USER"),
    password=os.getenv("DB_PASSWORD"),
    database=os.getenv("DB_NAME")
)

engine = create_engine(
    f'mysql+mysqlconnector://{os.getenv("DB_USER")}:{os.getenv("DB_PASSWORD")}@{os.getenv("DB_HOST")}/{os.getenv("DB_NAME")}'
)

Data Ingestion

Source data (patients.csv, vitals, lab values) is loaded into a local MySQL database. Tables are written with if_exists='replace' rather than 'append' — this makes re-running the notebook idempotent.

An earlier version of this pipeline used 'append', which silently duplicated rows on every re-run and inflated the dataset from ~222 patients to over 2 million rows before this was caught and fixed.

In [5]:
df_patients = pd.read_csv(r'/Users/samikshatripathy/Downloads/patients (1).csv')
df_patients = pd.DataFrame(df_patients)
df_patients.head()
Out[5]:
subject_id hadm_id stay_id intime outtime gender race arrival_transport disposition
0 10014729 23300884.0 37887480 3/19/2125 12:36 3/19/2125 16:59 F WHITE - OTHER EUROPEAN WALK IN ADMITTED
1 10018328 26706939.0 34176810 02/05/54 17:09 02/05/54 22:54 F WHITE AMBULANCE ADMITTED
2 10018328 NaN 32103106 08/03/54 15:31 08/03/54 22:29 F WHITE AMBULANCE HOME
3 10020640 27984218.0 38797992 02/12/53 21:59 2/13/2153 1:38 F WHITE AMBULANCE ADMITTED
4 10015272 27993466.0 33473053 06/12/37 16:54 06/12/37 18:37 F WHITE AMBULANCE ADMITTED
In [6]:
df_patients = df_patients[['subject_id', 'stay_id','gender','race','intime']]
df_patients['intime'] = pd.to_datetime(df_patients['intime'], format='mixed')
df_patients.to_sql('patients',con = engine,if_exists = 'replace',index = False)
Out[6]:
222
In [7]:
print(df_patients.shape)
print(df_patients['subject_id'].nunique())
print(df_patients['stay_id'].nunique())
dupe_ids = df_patients[df_patients.duplicated('subject_id', keep=False)]
print(dupe_ids.sort_values('subject_id').head(20))
(222, 5)
64
222
     subject_id   stay_id gender                    race              intime
38     10000032  33258284      F                   WHITE 1980-05-06 19:17:00
40     10000032  32952584      F                   WHITE 2180-07-22 16:24:00
41     10000032  38112554      F                   WHITE 2180-06-26 15:54:00
42     10000032  35968195      F                   WHITE 1980-08-05 20:58:00
39     10000032  39399961      F                   WHITE 2180-07-23 05:54:00
9      10001217  39866888      F                   WHITE 2157-11-18 17:38:00
10     10001217  39300221      F                   WHITE 2157-11-29 19:28:00
121    10002428  32822973      F                   WHITE 2156-04-30 18:30:00
120    10002428  37376268      F                   WHITE 2160-04-14 09:01:00
119    10002428  38216551      F                   WHITE 2160-07-15 17:34:00
118    10002428  33227245      F                   WHITE 2055-12-04 18:34:00
117    10002428  33978784      F                   WHITE 2056-05-11 11:29:00
116    10002428  32007337      F                   WHITE 2056-04-12 09:56:00
115    10002428  34982171      F                   WHITE 2157-07-16 01:50:00
102    10002930  32272346      F  BLACK/AFRICAN AMERICAN 2000-06-04 16:38:00
103    10002930  31579293      F  BLACK/AFRICAN AMERICAN 2196-04-14 05:55:00
108    10002930  35114287      F  BLACK/AFRICAN AMERICAN 2199-02-17 14:32:00
105    10002930  30193781      F  BLACK/AFRICAN AMERICAN 2198-04-17 11:42:00
107    10002930  35540249      F  BLACK/AFRICAN AMERICAN 2197-04-16 22:57:00
109    10002930  39910144      F  BLACK/AFRICAN AMERICAN 2001-02-12 15:11:00
In [8]:
df_patients.info()
<class 'pandas.DataFrame'>
RangeIndex: 222 entries, 0 to 221
Data columns (total 5 columns):
 #   Column      Non-Null Count  Dtype         
---  ------      --------------  -----         
 0   subject_id  222 non-null    int64         
 1   stay_id     222 non-null    int64         
 2   gender      222 non-null    str           
 3   race        222 non-null    str           
 4   intime      222 non-null    datetime64[us]
dtypes: datetime64[us](1), int64(2), str(2)
memory usage: 8.8 KB
In [9]:
df_vitals = pd.read_csv(r"/Users/samikshatripathy/Downloads/vital_sign.csv")
df_vitals = pd.DataFrame(df_vitals)
df_vitals = df_vitals.rename(columns={"heartrate": "heart_rate"})
df_vitals.head()
Out[9]:
subject_id stay_id charttime temperature heart_rate resprate o2sat sbp dbp rhythm pain
0 10014729 37887480 3/19/2125 13:22 NaN 124.0 24.0 100.0 93.0 65.0 NaN NaN
1 10014729 37887480 3/19/2125 18:28 98.9 106.0 18.0 100.0 115.0 70.0 Sinus Tachycardia 5
2 10014729 37887480 3/19/2125 13:07 NaN 128.0 18.0 100.0 132.0 96.0 Sinus Tachycardia NaN
3 10014729 37887480 3/19/2125 16:23 99.8 115.0 22.0 97.0 114.0 45.0 Sinus Tachycardia 0
4 10014729 37887480 3/19/2125 13:24 NaN 125.0 NaN NaN NaN NaN NaN NaN
In [10]:
df_vitals.info
Out[10]:
<bound method DataFrame.info of       subject_id   stay_id        charttime  temperature  heart_rate  \
0       10014729  37887480  3/19/2125 13:22          NaN       124.0   
1       10014729  37887480  3/19/2125 18:28         98.9       106.0   
2       10014729  37887480  3/19/2125 13:07          NaN       128.0   
3       10014729  37887480  3/19/2125 16:23         99.8       115.0   
4       10014729  37887480  3/19/2125 13:24          NaN       125.0   
...          ...       ...              ...          ...         ...   
1033    10002428  32822973  4/30/2156 18:47          NaN       100.0   
1034    10002428  32822973  4/30/2156 20:14          NaN        90.0   
1035    10002428  32822973  4/30/2156 19:47          NaN        96.0   
1036    10002428  33227245  12/4/2155 21:31         97.4        82.0   
1037    10002428  33227245  12/4/2155 21:36         97.4        82.0   

      resprate  o2sat    sbp   dbp             rhythm pain  
0         24.0  100.0   93.0  65.0                NaN  NaN  
1         18.0  100.0  115.0  70.0  Sinus Tachycardia    5  
2         18.0  100.0  132.0  96.0  Sinus Tachycardia  NaN  
3         22.0   97.0  114.0  45.0  Sinus Tachycardia    0  
4          NaN    NaN    NaN   NaN                NaN  NaN  
...        ...    ...    ...   ...                ...  ...  
1033      30.0   98.0  100.0  55.0                NaN    0  
1034      26.0    NaN   80.0  41.0                NaN    0  
1035      29.0    NaN  104.0  56.0                NaN    0  
1036      16.0  100.0  148.0  85.0                NaN  NaN  
1037      18.0  100.0  148.0  75.0                NaN    0  

[1038 rows x 11 columns]>

Why vitals are aggregated to one row per stay

stay_id is a primary key in patients (one row per stay) but a foreign key in vital, where it legitimately repeats — one row per vitals reading, several readings per stay. That's normal relational structure.

The bug: lab_result was originally built by copying vital's row structure directly, giving it the same one-to-many repetition. When the view joined two tables that were both one-to-many against the same stay_id, SQL paired every matching row on both sides — a many-to-many join that multiplied ~222 patients into millions of nonsensical rows (a vitals reading from 2am paired with a lab value that was really copied from an unrelated 6pm reading).

Fix: both vital and lab_result are aggregated to exactly one row per stay_id before the join, so each row in the final dataset represents one genuine patient encounter — which is also the right unit for this prediction task in the first place.

In [11]:
df_vitals_agg = df_vitals.groupby('stay_id').agg(
    heart_rate_mean=('heart_rate', 'mean'),
    heart_rate_max=('heart_rate', 'max'),
    resprate_mean=('resprate', 'mean'),
    o2sat_mean=('o2sat', 'mean'),
    sbp_mean=('sbp', 'mean'),
    sbp_min=('sbp', 'min'),
    dbp_mean=('dbp', 'mean')
).reset_index()

print(df_vitals_agg.shape)
print(df_vitals_agg.columns.tolist())
(206, 8)
['stay_id', 'heart_rate_mean', 'heart_rate_max', 'resprate_mean', 'o2sat_mean', 'sbp_mean', 'sbp_min', 'dbp_mean']
In [12]:
df_vitals_agg.to_sql("vital",con=engine,if_exists ="replace",index =False)
Out[12]:
206
In [13]:
def stimulate_hb(sbp, rng=np.random.default_rng(42)):
    if sbp < 90:
        return round(rng.uniform(6.0, 8.5), 1)
    else:
        return round(rng.uniform(11.0, 15.0), 1)
In [14]:
df_lab = df_vitals_agg[["stay_id", "sbp_min"]].copy()
df_lab["hemoglobin"] = df_lab['sbp_min'].apply(stimulate_hb)

df_lab[['stay_id', 'hemoglobin']].to_sql('lab_result', con=engine, if_exists='replace', index=False)
print(df_lab.shape)
(206, 3)

Data Cleaning

Missing value handling follows these steps:

  1. Coerce to numeric — pd.to_numeric(..., errors='coerce') turns any unparseable values into NaN.
  2. Sentinel values → NaN — zero/negative readings are physiologically impossible for vitals like heart rate or blood pressure, so they're treated as missing rather than real measurements. This is applied only to continuous vitals/lab columns, never to categorical or label columns (where 0 can be a valid value, not an error).
  3. Median imputation — missingness here is low (under 6% per column across ~638k source readings before aggregation), so simple median fill is a reasonable choice. Higher missingness would call for dropping the column or adding a missingness-indicator flag instead.

Gender is handled separately via label encoding, not the numeric-coercion pipeline, since it's categorical, not continuous.

In [15]:
df = pd.read_sql("select * from vw_transfusion_input",engine)
print(df.head())
df.shape
   subject_id   stay_id Gender  heart_rate_mean  heart_rate_max  \
0    10014729  37887480      F       117.666667           128.0   
1    10018328  34176810      F        66.200000            74.0   
2    10018328  32103106      F        73.000000            76.0   
3    10020640  38797992      F       121.250000           129.0   
4    10015272  33473053      F       123.000000           125.0   

   resprate_mean  o2sat_mean    sbp_mean  sbp_min  dbp_mean  hemoglobin  \
0      20.800000   98.800000  117.000000     93.0      67.2        12.8   
1      18.400000   98.200000  151.000000    133.0      61.8        12.2   
2      17.333333   98.333333  137.666667    122.0      73.0        14.3   
3      31.000000   97.750000  115.000000    105.0      76.5        14.8   
4      20.000000   97.400000   94.000000     90.0      62.2        11.1   

   needs_transfusion  
0                  0  
1                  0  
2                  0  
3                  0  
4                  0  
Out[15]:
(206, 12)
In [16]:
print(df.isna().sum())
subject_id           0
stay_id              0
Gender               0
heart_rate_mean      0
heart_rate_max       0
resprate_mean        1
o2sat_mean           2
sbp_mean             0
sbp_min              0
dbp_mean             0
hemoglobin           0
needs_transfusion    0
dtype: int64
In [17]:
print(df.info())
<class 'pandas.DataFrame'>
RangeIndex: 206 entries, 0 to 205
Data columns (total 12 columns):
 #   Column             Non-Null Count  Dtype  
---  ------             --------------  -----  
 0   subject_id         206 non-null    int64  
 1   stay_id            206 non-null    int64  
 2   Gender             206 non-null    str    
 3   heart_rate_mean    206 non-null    float64
 4   heart_rate_max     206 non-null    float64
 5   resprate_mean      205 non-null    float64
 6   o2sat_mean         204 non-null    float64
 7   sbp_mean           206 non-null    float64
 8   sbp_min            206 non-null    float64
 9   dbp_mean           206 non-null    float64
 10  hemoglobin         206 non-null    float64
 11  needs_transfusion  206 non-null    int64  
dtypes: float64(8), int64(3), str(1)
memory usage: 19.4 KB
None
In [18]:
df.dtypes
Out[18]:
subject_id             int64
stay_id                int64
Gender                   str
heart_rate_mean      float64
heart_rate_max       float64
resprate_mean        float64
o2sat_mean           float64
sbp_mean             float64
sbp_min              float64
dbp_mean             float64
hemoglobin           float64
needs_transfusion      int64
dtype: object
In [19]:
print(df['stay_id'].duplicated().sum())  
0
In [20]:
print(df.columns.tolist())
['subject_id', 'stay_id', 'Gender', 'heart_rate_mean', 'heart_rate_max', 'resprate_mean', 'o2sat_mean', 'sbp_mean', 'sbp_min', 'dbp_mean', 'hemoglobin', 'needs_transfusion']
In [21]:
print(df.shape)
print(df[["heart_rate_mean","resprate_mean","o2sat_mean","sbp_min","dbp_mean"]].isna().mean()*100)
(206, 12)
heart_rate_mean    0.000000
resprate_mean      0.485437
o2sat_mean         0.970874
sbp_min            0.000000
dbp_mean           0.000000
dtype: float64
In [22]:
numeric_col = df[["heart_rate_mean","resprate_mean","o2sat_mean","sbp_min","dbp_mean"]]
for col in numeric_col:
    pd.to_numeric(df[col])
In [23]:
# Relacing the zeros/negatives with NAN values
for col in numeric_col:
    df.loc[df[col]<=0,col] = np.nan
    
In [24]:
for col in numeric_col:
    df[col] = df[col].fillna(df[col].median())
In [25]:
print(df.isna().sum())
# no NAN vales 
subject_id           0
stay_id              0
Gender               0
heart_rate_mean      0
heart_rate_max       0
resprate_mean        0
o2sat_mean           0
sbp_mean             0
sbp_min              0
dbp_mean             0
hemoglobin           0
needs_transfusion    0
dtype: int64
In [26]:
print(df['Gender'].unique())
<StringArray>
['F', 'M']
Length: 2, dtype: str
In [27]:
lb = LabelEncoder()
df['Gender'] = lb.fit_transform(df['Gender'].astype(str))

Label Construction and Data Leakage

needs_transfusion is defined by a rule in the vw_transfusion_input SQL view:

CASE WHEN (hemoglobin < 7.5) OR (sbp_min < 90 AND heart_rate_max > 100)
     THEN 1 ELSE 0 END

This is a synthetic, rule-derived label for demo purposes — not a real clinical transfusion-order event. Two rounds of data leakage were found and fixed while building this pipeline:

  1. First leakage: an early version of this notebook additionally overwrote needs_transfusion in pandas using a formula built directly from hemoglobin and sbp — the same columns used as model features. This let the model trivially "predict" the label by recovering the formula, producing a meaningless perfect score.
  2. Second leakage (same root cause, one layer deeper): after removing the pandas formula, the model still scored a perfect 1.0 ROC-AUC. Tracing this back showed the SQL view itself was built using the same kind of hard threshold on hemoglobin, sbp_min, and heart_rate_max — so the "real" label was still deterministic given those columns.

Fix: hemoglobin, sbp_min, and heart_rate_max — along with shock_index_mean and pulse_pressure_mean, which are derived from those same columns and therefore still leak the same information indirectly — are excluded from the feature set X. The model is trained only on genuinely independent signals: Gender, heart_rate_mean, resprate_mean, o2sat_mean, sbp_mean, dbp_mean.

In [28]:
df['needs_transfusion'].value_counts()
Out[28]:
needs_transfusion
0    192
1     14
Name: count, dtype: int64
In [29]:
df["shock_index_mean"] = df["heart_rate_mean"]/df["sbp_min"]
df["pulse_pressure_mean"] = df["sbp_min"] - df["dbp_mean"]
df.head()
Out[29]:
subject_id stay_id Gender heart_rate_mean heart_rate_max resprate_mean o2sat_mean sbp_mean sbp_min dbp_mean hemoglobin needs_transfusion shock_index_mean pulse_pressure_mean
0 10014729 37887480 0 117.666667 128.0 20.800000 98.800000 117.000000 93.0 67.2 12.8 0 1.265233 25.8
1 10018328 34176810 0 66.200000 74.0 18.400000 98.200000 151.000000 133.0 61.8 12.2 0 0.497744 71.2
2 10018328 32103106 0 73.000000 76.0 17.333333 98.333333 137.666667 122.0 73.0 14.3 0 0.598361 49.0
3 10020640 38797992 0 121.250000 129.0 31.000000 97.750000 115.000000 105.0 76.5 14.8 0 1.154762 28.5
4 10015272 33473053 0 123.000000 125.0 20.000000 97.400000 94.000000 90.0 62.2 11.1 0 1.366667 27.8
In [30]:
print(df["shock_index_mean"].isna().sum())
print(df["pulse_pressure_mean"].isna().sum())
0
0

Exploratory Data Analysis (EDA)

In [31]:
type_ = ["no","yes"]
fig = make_subplots(rows =1,cols =1)

night_colour = ["#BCD9A2","#A82323"]
fig.add_trace(go.Pie(labels = type_ , values = df["needs_transfusion"].value_counts(), name = "Transfusion", marker_colors = night_colour ))

fig.update_traces(hole = 0.4,hoverinfo="label+percent+name",textfont_size = 16)

fig.update_layout(title_text="<b> Blood Transfusion Pie Chart <b>",
                 width = 500,
                 height = 500,
                annotations = [dict(text='Tranfusion',x=0.5,y=0.5,showarrow = False)])
fig.show()
No description has been provided for this image
In [32]:
cols = ['heart_rate_mean', 'sbp_min', 'dbp_mean', 'o2sat_mean', 'resprate_mean', 'hemoglobin', 'shock_index_mean', 'pulse_pressure_mean']
fig = make_subplots(rows=2, cols=4, subplot_titles=cols)

for i, col in enumerate(cols):
    row = i // 4 + 1
    col_pos = i % 4 + 1
    for outcome in [0, 1]:
        fig.add_trace(
            go.Box(y=df[df['needs_transfusion']==outcome][col], name=str(outcome), showlegend=(i==0)),
            row=row, col=col_pos
        )

fig.update_layout(height=700, title_text="Vitals by Transfusion Need")
fig.show()
No description has been provided for this image
In [33]:
import plotly.express as px

corr = df.corr(numeric_only=True).round(2)

fig = px.imshow(
    corr,
    text_auto=True,           # shows numbers on each cell
    color_continuous_scale='Viridis',
    aspect='auto',
    labels=dict(color='Correlation')
)

fig.update_layout(
    width=800,
    height=800,
    font=dict(size=10),       # shrink font so numbers fit in cells
    title='Correlation Heatmap'
)

fig.update_traces(textfont_size=10)  # controls the number size inside cells specifically

fig.show()
No description has been provided for this image
In [34]:
print(df[["heart_rate_mean","resprate_mean","o2sat_mean","sbp_min","dbp_mean"]].describe())
       heart_rate_mean  resprate_mean  o2sat_mean     sbp_min    dbp_mean
count       206.000000     206.000000  206.000000  206.000000  206.000000
mean         86.670010      18.112986   97.897912  116.946602   70.060693
std          17.100514       2.576538    2.185874   22.862304   13.077913
min          53.954545      12.250000   76.750000   11.000000   41.500000
25%          72.450000      16.666667   97.000000  104.000000   60.333333
50%          85.500000      17.666667   98.333333  116.000000   70.900000
75%          98.916667      18.666667   99.160714  129.000000   78.821429
max         131.000000      31.000000  100.000000  180.000000  131.500000
In [35]:
df.groupby('Gender')['needs_transfusion'].mean()
Out[35]:
Gender
0    0.078947
1    0.054348
Name: needs_transfusion, dtype: float64

Handling Class Imbalance

needs_transfusion is rare (14 positive cases out of 206 rows, ~6.8%). SMOTE generates synthetic minority-class examples by interpolating between real positive cases, so the model isn't incentivized to just predict the majority class for every row.

Important: SMOTE is applied inside the cross-validation pipeline (imblearn.pipeline.Pipeline), not once on the whole dataset beforehand. Applying it before splitting would let synthetic points generated from training-fold data leak into test folds — the same category of mistake as the label leakage above, just relocated into the evaluation step.

Smote fixes the training imbalance problem beacuse without it the model can get high accuracy by just predicting "no transfusion" for everyone, since that's right 95% of the time, and SMOTE stops that lazy shortcut by making the positive class numerically comparable during training.

The model can learn who needs tranfusion only set by those 14 real examples only, it dosn't matter how many synethetic points we get generated from them.

In [36]:
columns_to_drop = [
    'needs_transfusion', 'stay_id', 'subject_id',
    'hemoglobin', 'sbp_min', 'heart_rate_max',      # directly in the label formula
    'shock_index_mean', 'pulse_pressure_mean'        # derived from sbp_min, so still leaky
]
X = df.drop(columns=columns_to_drop, errors='ignore')
Y = df['needs_transfusion']
print(X.columns.tolist())
['Gender', 'heart_rate_mean', 'resprate_mean', 'o2sat_mean', 'sbp_mean', 'dbp_mean']

Cross-Validation

A single 80/20 train/test split proved unreliable here — with only 14 positive cases total, one particular split can land 2-3 (or even 0-1) positive cases in the test set, making any single score highly sensitive to which rows happened to land where.

StratifiedKFold(n_splits=5) is used instead: the data is divided into 5 folds, each preserving the same ~6.8% positive rate, and every row is used as test data exactly once across the 5 rounds. Reporting the mean and standard deviation across folds (rather than a single number) makes the uncertainty in this estimate explicit, which matters given the small sample size.

In [37]:
cv = StratifiedKFold(n_splits = 5, shuffle = True, random_state = 42)

rf_pipeline = ImbPipeline([('smote',SMOTE(random_state = 42, k_neighbors = 3)),
                           ('model',RandomForestClassifier(n_estimators=300, max_depth=5, random_state=42,n_jobs=-1))
                          ])
In [49]:
rf_scores = cross_val_score(rf_pipeline,X,Y, cv = cv, scoring= 'roc_auc')
print("Per-fold ROC-AUC", np.round(rf_scores,3))
print(f"Mean:{rf_scores.mean():.3f} ± Std: {rf_scores.std():3f}")
Per-fold ROC-AUC [0.752 0.833 0.798 0.693 0.877]
Mean:0.791 ± Std: 0.063861
In [39]:
oof_proba = cross_val_predict(rf_pipeline,X,Y,cv=cv,method='predict_proba')[:,1]

results_oof = pd.DataFrame({'actual': Y.values, 'predicted_proba':oof_proba})
print(results_oof.sort_values('predicted_proba', ascending=False).head(20))
     actual  predicted_proba
20        0         0.924805
59        0         0.902873
62        0         0.898920
37        0         0.872409
86        0         0.798707
15        0         0.740705
93        1         0.737939
38        0         0.726643
35        1         0.721395
33        1         0.699684
24        0         0.687717
4         0         0.672714
92        0         0.653069
108       0         0.647888
172       0         0.630537
184       0         0.627969
94        0         0.626095
113       1         0.618722
23        0         0.615203
5         0         0.592275

''' This is a demo data the majority of classes where classified as not in a need of tranfusion so the accuracy of the model will be very high so we wont be using that as a feild for classification report'''

Model Comparison: Random Forest vs. Logistic Regression

Logistic Regression is included as an interpretable baseline. With this little data, a simpler model with fewer parameters can generalize better than a more flexible one — Random Forest has more room to fit noise in the training folds, while Logistic Regression's simplicity acts as a form of regularization almost by construction. Both models are evaluated on the same 5 folds (same cv object passed to both), so any difference in score reflects the models themselves, not random variation in how the data was split.

In [40]:
# cv already defined

logreg_pipeline = ImbPipeline([('smote',SMOTE(random_state=42, k_neighbors = 3)),
                              ('model',LogisticRegression(max_iter=1000,random_state=42))
                              ])
In [41]:
logreg_scores = cross_val_score(logreg_pipeline, X, Y, cv=cv, scoring='roc_auc')
print("Per-fold ROC-AUC:", np.round(logreg_scores, 3))
print(f"Mean: {logreg_scores.mean():.3f}  ±  Std: {logreg_scores.std():.3f}")
Per-fold ROC-AUC: [0.479 0.782 0.93  0.895 0.974]
Mean: 0.812  ±  Std: 0.178

Model comparison (cross-validated, 5 folds):

Model Mean ROC-AUC Std Notes
Random Forest (+ SMOTE) 0.791 0.063 Non-monotonic on heart rate (see findings)
Logistic Regression (+ SMOTE) 0.812 0.178 Interpretable baseline; matched or outperformed RF

Threshold Selection and Calibration

Out-of-fold predicted probabilities (cross_val_predict) are used here rather than a single test set's predictions — this uses every row's prediction from when it was held out across the 5 folds, giving a fuller and less noisy picture than the 42-row single split used earlier.

The default 0.5 classification threshold isn't assumed to be correct. Instead, a precision-recall curve is used to choose a threshold deliberately: for a transfusion-need screening use case, missing a real positive case (false negative) is arguably costlier than an unnecessary follow-up check (false positive), so a threshold favoring recall is a defensible, explicit choice — not the default.

A calibration curve checks whether predicted probabilities are trustworthy in their own right (e.g. does "70% predicted risk" really correspond to about 70% of such cases being positive). With only 14 positive cases, expect this curve to look noisy rather than a smooth diagonal — that's an honest reflection of the sample size, not a modeling error.

In [42]:
precision , recall, thresholds = precision_recall_curve(Y,oof_proba)

plt.figure(figsize=(7,5))
plt.plot(thresholds, precision[:-1], label = 'Precision')
plt.plot(thresholds, recall[:-1], label = 'recall')
plt.xlabel('Threshold')
plt.ylabel('Score')
plt.title('Precision and Recall vs Tresholds')
plt.legend()
plt.show()

target_recall = 0.8
idx = np.where(recall >= target_recall)[0][-1]

print(f"Thresholds for >= {target_recall} recall: {thresholds[idx]:.3f}")
print(f"At that threshold — precision: {precision[idx]:.3f}, recall: {recall[idx]:.3f}")
No description has been provided for this image
Thresholds for >= 0.8 recall: 0.109
At that threshold — precision: 0.171, recall: 0.857
In [43]:
rac_pos, mean_pred = calibration_curve(Y, oof_proba, n_bins=5, strategy='quantile')

plt.figure(figsize=(6,6))
plt.plot(mean_pred, rac_pos, marker='o', label='Random Forest')
plt.plot([0,1],[0,1], linestyle='--', color='gray', label='Perfectly calibrated')
plt.xlabel('Mean predicted probability')
plt.ylabel('Fraction of actual positives')
plt.title('Calibration Curve')
plt.legend()
plt.show()
No description has been provided for this image

Final Model and Deployment

Cross-validation is used for evaluating the model honestly — not for producing the final deployed model. The final model is refit on the entire available dataset (all 206 rows, SMOTE-balanced), since more training data is better once evaluation is complete. This final model is exported to JavaScript via m2cgen, allowing it to run entirely client-side in the accompanying interactive demo (transfusion_dashboard.html) with no server or API required.

In [44]:
final_model = RandomForestClassifier(n_estimators=300, max_depth=5, random_state=42)
sm_final = SMOTE(random_state=42, k_neighbors=3)
X_res, Y_res = sm_final.fit_resample(X, Y)
final_model.fit(X_res, Y_res)

print("Features model expects:", final_model.feature_names_in_)

code = m2c.export_to_javascript(final_model)
with open("model.js", "w") as f:
    f.write(code)
Features model expects: ['Gender' 'heart_rate_mean' 'resprate_mean' 'o2sat_mean' 'sbp_mean'
 'dbp_mean']

Limitations

  • Sample size. 206 patient stays and 14 positive cases is small; all metrics here should be read as indicative, not definitive. Cross-validation standard deviations reflect this directly.
  • Synthetic label. needs_transfusion is rule-derived for this demo, not a real clinical outcome — this project demonstrates a leakage-aware, honestly evaluated ML methodology, not a validated clinical tool.
  • No additional real data was available for this iteration. A larger patient sample would likely improve reliability more than further modeling changes.
  • Age is collected in the interactive demo for context but is not one of the model's trained features.

End.

In [ ]: