← Back to list

🩸 Blood Cell Anomaly Detection: A Complete Tabular ML Pipeline

3 Tasks, 4 Models, SHAP Explainability β€” and How to Avoid the #1 Pitfall in Medical ML

GΓΆktan Δ°ren Β· 2026-03-17 12:42 Β· 10 claps Β· 20.2 min read
#hematology #machine-learning #healthcare #data-science #python
Open on Medium β†—
Wiki topics: ML Β· Machine Learning EDU Β· Education & Learning πŸ”¬ Β· Science Β· General

🩸 Blood Cell Anomaly Detection: A Complete Tabular ML Pipeline

3 Tasks, 4 Models, SHAP Explainability β€” and How to Avoid the #1 Pitfall in Medical ML

Dataset: 5,880 blood cell records Γ— 36 features Γ— 19 cell types Inspired by: CytoDiffusion β€” Nature Machine Intelligence (2025) Β· Cambridge Β· UCL Β· QMUL SOTA Benchmark: AUC = 0.990 (image-based vision model)

This article walks through a complete, production-style machine learning pipeline for detecting blood cell anomalies using only tabular morphological and clinical features β€” no microscopy images required. We tackle three clinically meaningful prediction tasks, train four different models, explain predictions with SHAP, and apply eight enhancements to push performance further.

πŸ“‹ What This Pipeline Covers

Step Task Model Target Metric 1 Binary: Normal vs Anomaly XGBoost + SMOTE AUC > 0.90 2 Multi-class: 19 cell types LightGBM + PyTorch MLP Macro-F1 > 0.85 3 Disease-level prediction XGBoost Recall > 0.90 (Leukemia/Infection) 4 Explainability SHAP Feature importance

⚠️ Critical Warning β€” Data Leakage: The dataset includes three AI-generated score columns (cytodiffusion_anomaly_score, classification_confidence, labeller_confidence) that directly encode the answer. This notebook removes them before training. Without this fix, models score AUC β‰ˆ 1.000 β€” which is not real learning. This is the single most important lesson in this entire pipeline.

Step 1 β€” Import Libraries

All required libraries for data processing, ML models, deep learning, and explainability are imported upfront. We also configure a global matplotlib style for clean, consistent plots.

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns
import warnings
warnings.filterwarnings('ignore')
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler, LabelEncoder
from sklearn.metrics import (
    classification_report, confusion_matrix,
    roc_auc_score, f1_score, accuracy_score, roc_curve, recall_score
)
from sklearn.utils.class_weight import compute_class_weight
import xgboost as xgb
import lightgbm as lgb
from imblearn.over_sampling import SMOTE
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset
import shap
# ── Global plot style ─────────────────────────────────────────────
plt.rcParams.update({
    'figure.facecolor': 'white',
    'axes.facecolor':   '#f8f9fc',
    'axes.grid':        True,
    'grid.alpha':       0.4,
    'axes.spines.top':  False,
    'axes.spines.right':False,
})
DEVICE = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f"PyTorch {torch.__version__} | Device: {DEVICE}")

Why these libraries?

  • xgboost and lightgbm β€” the two dominant gradient boosting frameworks for tabular data; consistently top performers on medical datasets.
  • imblearn.SMOTE β€” handles class imbalance by synthetically generating minority-class samples in feature space rather than simply duplicating rows.
  • shap β€” model-agnostic explainability; critical for medical applications where predictions must be justifiable to clinicians.
  • torch β€” used for a custom MLP that demonstrates how deep learning compares to tree-based ensembles on this tabular problem.

Step 2 β€” Load Data

The dataset consists of three CSV files. The main table contains 5,880 blood cell observations across 36 features and 19 cell type labels.

BASE = '/kaggle/input/datasets/alitaqishah/blood-cell-anomaly-detection-2025'
df       = pd.read_csv(f'{BASE}/blood_cell_anomaly_detection.csv')
ref_df   = pd.read_csv(f'{BASE}/cell_type_reference.csv')
bench_df = pd.read_csv(f'{BASE}/cytodiffusion_benchmark_scores.csv')
TARGET_COL = 'cell_type' if 'cell_type' in df.columns else df.columns[-1]
print(f"Shape         : {df.shape}")
print(f"Target column : {TARGET_COL}")
print(f"Classes       : {df[TARGET_COL].nunique()}")
print(f"Missing values: {df.isnull().sum().sum()}")
print()
print("── Column list ──────────────────────────────")
for i, col in enumerate(df.columns, 1):
    dtype = str(df[col].dtype)
    print(f"  {i:2d}. {col:<40} {dtype}")

Files loaded:

  • blood_cell_anomaly_detection.csv β€” the main 5,880 Γ— 36 feature table with cell type labels.
  • cell_type_reference.csv β€” clinical context and descriptions for each of the 19 cell types.
  • cytodiffusion_benchmark_scores.csv β€” the published SOTA scores from the CytoDiffusion paper for benchmarking.

Step 3 β€” Exploratory Data Analysis (EDA)

3.1 Cell Type Distribution

Before training any model, we visualize the class distribution. This reveals two important facts: (1) the dataset is imbalanced across 19 cell types, and (2) the binary split between Normal and Anomaly cells matters for task design.

Color legend:

  • πŸ”΅ Blue β†’ Normal cells (Neutrophil, Lymphocyte, Monocyte, Eosinophil, Basophil, Normal RBC, Platelet)
  • πŸ”΄ Red β†’ Leukemia (Blast Cell, Prolymphocyte)
  • 🟠 Orange β†’ Anemia (Elliptocyte, Schistocyte, Spherocyte, Target Cell)
  • 🟑 Yellow β†’ Sickle Cell Disease
  • 🟣 Purple β†’ Infection (Hypersegmented Neutrophil, Toxic Granulation, Reactive Lymphocyte)
  • ⚫ Gray β†’ Artefact (Smudge Cell, Artefact)
NORMAL_TYPES = [
    'Neutrophil', 'Lymphocyte', 'Monocyte', 'Eosinophil',
    'Basophil', 'Normal_RBC', 'Platelet', 'Normal RBC'
]
CLASS_COLORS = {
    'Neutrophil': '#60a5fa',   'Lymphocyte': '#60a5fa',
    'Monocyte': '#60a5fa',     'Eosinophil': '#60a5fa',
    'Basophil': '#60a5fa',     'Normal_RBC': '#60a5fa',
    'Platelet': '#60a5fa',     'Normal RBC': '#60a5fa',
    'Blast_Cell': '#f87171',   'Blast Cell': '#f87171',
    'Prolymphocyte': '#f87171',
    'Elliptocyte': '#fb923c',  'Schistocyte': '#fb923c',
    'Spherocyte': '#fb923c',   'Target_Cell': '#fb923c',
    'Target Cell': '#fb923c',
    'Sickle_Cell': '#facc15',  'Sickle Cell': '#facc15',
    'Hypersegmented_Neutrophil': '#a78bfa',
    'Toxic_Granulation': '#a78bfa',
    'Reactive_Lymphocyte': '#a78bfa',
    'Smudge_Cell': '#94a3b8',  'Smudge Cell': '#94a3b8',
    'Artefact': '#94a3b8',
}
fig, axes = plt.subplots(1, 2, figsize=(16, 6))
fig.suptitle('Blood Cell Type Distribution', fontsize=15, fontweight='bold', y=1.01)
# ── Left: 19-class bar chart ──────────────────────────────────────
counts = df[TARGET_COL].value_counts()
bar_colors = [CLASS_COLORS.get(c, '#94a3b8') for c in counts.index]
axes[0].barh(counts.index, counts.values, color=bar_colors, edgecolor='white', linewidth=0.5)
axes[0].set_title('All 19 Cell Types', fontsize=12, fontweight='bold')
axes[0].set_xlabel('Sample Count')
for i, (val, name) in enumerate(zip(counts.values, counts.index)):
    axes[0].text(val + 8, i, str(val), va='center', fontsize=8, color='#475569')
legend_patches = [
    mpatches.Patch(color='#60a5fa', label='Normal (7 types)'),
    mpatches.Patch(color='#f87171', label='Leukemia (2)'),
    mpatches.Patch(color='#fb923c', label='Anemia (4)'),
    mpatches.Patch(color='#facc15', label='Sickle Cell (1)'),
    mpatches.Patch(color='#a78bfa', label='Infection (3)'),
    mpatches.Patch(color='#94a3b8', label='Artefact (2)'),
]
axes[0].legend(handles=legend_patches, loc='lower right', fontsize=8)
# ── Right: Binary pie chart ───────────────────────────────────────
is_anomaly = (~df[TARGET_COL].isin(NORMAL_TYPES)).astype(int)
bc = is_anomaly.value_counts()
normal_n  = bc.get(0, 0)
anomaly_n = bc.get(1, 0)
wedges, texts, autotexts = axes[1].pie(
    [normal_n, anomaly_n],
    labels=[f'Normal\n(n={normal_n:,})', f'Anomaly\n(n={anomaly_n:,})'],
    colors=['#60a5fa', '#f87171'],
    autopct='%1.1f%%', startangle=90, pctdistance=0.75,
    wedgeprops={'edgecolor': 'white', 'linewidth': 2}
)
for at in autotexts:
    at.set_fontsize(12)
    at.set_fontweight('bold')
axes[1].set_title('Binary Split: Normal vs Anomaly', fontsize=12, fontweight='bold')
plt.tight_layout()
plt.savefig('eda_distribution.png', dpi=120, bbox_inches='tight', facecolor='white')
plt.show()

3.2 Feature Groups & Data Leakage

The 36 features fall into 4 legitimate groups β€” plus 3 columns that must be removed before any training begins.

Group Features Use in Training Morphology diameter, circularity, eccentricity, lobularity, granularity, nucleus_area, chromatin_density βœ… Yes Color mean_r, mean_g, mean_b, stain_intensity βœ… Yes Clinical CBC wbc_count, hemoglobin, hematocrit, mcv, mchc, platelet_count βœ… Yes Acquisition microscope_model, staining_protocol, magnification, resolution βœ… Yes ⚠️ AI Scores cytodiffusion_anomaly_score, classification_confidence, labeller_confidence ❌ REMOVED

Without removing AI scores β†’ AUC = 1.000 (the model is reading the answer key). After removing AI scores β†’ AUC β‰ˆ 0.88–0.93 (the model is actually learning).

# ── Identify and display leaked columns ──────────────────────────
LEAK_KEYWORDS = ['anomaly_score', 'classification_confidence',
                 'labeller_confidence', 'cytodiffusion']
LEAK_COLS = [c for c in df.columns
             if any(kw in c.lower() for kw in LEAK_KEYWORDS)]
print("Leaked columns removed from X:")
for col in LEAK_COLS:
    print(f"  βœ—  {col}")
# ── Feature correlation heatmap (clean features only) ─────────────
numeric_cols  = df.select_dtypes(include='number').columns.tolist()
clean_numeric = [c for c in numeric_cols if c not in LEAK_COLS]
fig, ax = plt.subplots(figsize=(14, 11))
corr = df[clean_numeric].corr()
mask = np.triu(np.ones_like(corr, dtype=bool))
sns.heatmap(
    corr, mask=mask, cmap='RdBu_r', center=0,
    linewidths=0.3, linecolor='white', annot=False,
    cbar_kws={'shrink': 0.7, 'label': 'Pearson r'}, ax=ax
)
ax.set_title('Feature Correlation Matrix  (AI score columns excluded)',
             fontsize=13, fontweight='bold', pad=14)
ax.tick_params(axis='x', rotation=45, labelsize=8)
ax.tick_params(axis='y', rotation=0,  labelsize=8)
plt.tight_layout()
plt.savefig('eda_correlation.png', dpi=120, bbox_inches='tight', facecolor='white')
plt.show()

The correlation heatmap helps identify feature groups that move together. Highly correlated features (|r| > 0.95) carry redundant information and can be pruned in Enhancement 1 to reduce noise and training time.

Step 4 β€” Preprocessing & Feature Engineering

Pipeline Summary

  1. Encode categorical columns with LabelEncoder
  2. Build 3 target vectors β€” binary, 19-class, and disease-level
  3. Drop leaked AI score columns from the feature matrix
  4. Split 70% train / 15% validation / 15% test (stratified)
  5. Scale with StandardScaler (fit on train only β€” no leakage)
  6. SMOTE oversampling on the training set for the binary task

Disease-Level Mapping

Rather than predicting exact cell types (19 classes), Task 3 maps each cell to its associated clinical condition β€” which is what clinicians actually care about.

Cell Types Disease Label Blast Cell, Prolymphocyte Leukemia Elliptocyte, Schistocyte, Spherocyte, Target Cell Anemia Sickle Cell Sickle Cell Disease Hypersegmented Neutrophil, Toxic Granulation, Reactive Lymphocyte Infection Smudge Cell, Artefact Artefact All 7 normal types Normal

# ── 1. Encode categorical columns ────────────────────────────────
cat_cols = df.select_dtypes(include='object').columns.tolist()
df_enc   = df.copy()
le_store = {}
for col in cat_cols:
    if col != TARGET_COL:
        le = LabelEncoder()
        df_enc[col] = le.fit_transform(df_enc[col].astype(str))
        le_store[col] = le
# ── 2. Build three target vectors ────────────────────────────────
le_target  = LabelEncoder()
y_multi    = le_target.fit_transform(df_enc[TARGET_COL])
class_names = le_target.classes_
y_binary = (~df_enc[TARGET_COL].isin(NORMAL_TYPES)).astype(int).values
DISEASE_MAP = {
    'Blast_Cell': 'Leukemia',    'Blast Cell': 'Leukemia',
    'Prolymphocyte': 'Leukemia',
    'Elliptocyte': 'Anemia',     'Schistocyte': 'Anemia',
    'Spherocyte': 'Anemia',      'Target_Cell': 'Anemia',
    'Target Cell': 'Anemia',
    'Sickle_Cell': 'Sickle Cell','Sickle Cell': 'Sickle Cell',
    'Hypersegmented_Neutrophil': 'Infection',
    'Toxic_Granulation': 'Infection',
    'Reactive_Lymphocyte': 'Infection',
    'Smudge_Cell': 'Artefact',   'Smudge Cell': 'Artefact',
    'Artefact': 'Artefact',
}
df_enc['disease'] = df_enc[TARGET_COL].map(DISEASE_MAP).fillna('Normal')
le_disease  = LabelEncoder()
y_disease   = le_disease.fit_transform(df_enc['disease'])
disease_names = le_disease.classes_
# ── 3. Build feature matrix (no leakage) ─────────────────────────
EXTRA_DROP = ['cell_id', 'anomaly_label', 'disease_category']
DROP_COLS  = [TARGET_COL, 'disease'] + LEAK_COLS + EXTRA_DROP
X             = df_enc.drop(columns=DROP_COLS).values
feature_names = df_enc.drop(columns=DROP_COLS).columns.tolist()
# ── 4. Train / Val / Test split ───────────────────────────────────
X_tmp, X_test, ym_tmp, ym_test, yb_tmp, yb_test, yd_tmp, yd_test = (
    train_test_split(X, y_multi, y_binary, y_disease,
                     test_size=0.15, random_state=42, stratify=y_multi)
)
X_train, X_val, ym_train, ym_val, yb_train, yb_val, yd_train, yd_val = (
    train_test_split(X_tmp, ym_tmp, yb_tmp, yd_tmp,
                     test_size=0.176, random_state=42, stratify=ym_tmp)
)
# ── 5. StandardScaler ─────────────────────────────────────────────
scaler     = StandardScaler()
X_train_sc = scaler.fit_transform(X_train)
X_val_sc   = scaler.transform(X_val)
X_test_sc  = scaler.transform(X_test)
# ── 6. SMOTE for binary task ──────────────────────────────────────
smote          = SMOTE(random_state=42)
X_train_sm, yb_train_sm = smote.fit_resample(X_train_sc, yb_train)
print(f"Feature matrix  : {X.shape}")
print(f"Train / Val / Test : {X_train.shape[0]} / {X_val.shape[0]} / {X_test.shape[0]}")
print(f"SMOTE train size   : {X_train_sm.shape[0]}  (original: {X_train.shape[0]})")
print(f"Disease classes    : {list(disease_names)}")
print(f"Cell type classes  : {list(class_names)}")

Key preprocessing decisions explained:

  • Stratified split: stratify=y_multi ensures every cell type appears proportionally in train, validation, and test sets β€” essential when some classes have very few examples.
  • Scaler fit on train only: If we fit StandardScaler on the full dataset, validation and test statistics bleed into training. Always fit_transform on train, then only transform on val/test.
  • SMOTE only on train: Applying SMOTE before splitting would create synthetic samples in both train and test, introducing leakage. SMOTE runs strictly inside the training set.
  • Multiple train_test_split calls: All three label arrays (binary, multi-class, disease) are split identically using the same indices, ensuring consistent evaluation across tasks.

Task 1 β€” Binary Classification: Normal vs Anomaly

Goal: Detect whether a blood cell is healthy or pathological using tabular morphological features.

Model: XGBoost with SMOTE oversampling Key hyperparameters: 300 trees, max_depth=6, lr=0.05, subsample=0.8 SOTA reference: CytoDiffusion AUC = 0.990 (image-based model) Our target: AUC > 0.90 with tabular features only

xgb_bin = xgb.XGBClassifier(
    n_estimators=300,
    max_depth=6,
    learning_rate=0.05,
    subsample=0.8,
    colsample_bytree=0.8,
    use_label_encoder=False,
    eval_metric='logloss',
    random_state=42,
    verbosity=0,
)
xgb_bin.fit(
    X_train_sm, yb_train_sm,
    eval_set=[(X_val_sc, yb_val)],
    verbose=False,
)
y_pred_bin  = xgb_bin.predict(X_test_sc)
y_proba_bin = xgb_bin.predict_proba(X_test_sc)[:, 1]
auc_bin     = roc_auc_score(yb_test, y_proba_bin)
f1_bin      = f1_score(yb_test, y_pred_bin)
acc_bin     = accuracy_score(yb_test, y_pred_bin)
print(f"ROC-AUC  : {auc_bin:.4f}  (SOTA = 0.990, Baseline = 0.916)")
print(f"F1-Score : {f1_bin:.4f}")
print(f"Accuracy : {acc_bin:.4f}")
print()
print(classification_report(yb_test, y_pred_bin, target_names=['Normal', 'Anomaly']))

Why XGBoost for binary detection? XGBoost’s tree-based splits naturally model the threshold-like morphological boundaries between normal and abnormal cells (e.g., nucleus area above a certain value, circularity below a threshold). The subsample=0.8 and colsample_bytree=0.8 introduce stochasticity to reduce overfitting. eval_set enables monitoring on the validation set via logloss, preventing over-training.

Task 1 β€” Results Visualization

Three panels: ROC Curve (trade-off at every threshold), Confusion Matrix (prediction breakdown), and a metric summary bar chart with SOTA reference lines.

fig, axes = plt.subplots(1, 3, figsize=(18, 5))
fig.suptitle('Task 1 β€” Binary Classification Results (Normal vs Anomaly)',
             fontsize=14, fontweight='bold', y=1.02)
# ── ROC Curve ─────────────────────────────────────────────────────
fpr, tpr, _ = roc_curve(yb_test, y_proba_bin)
axes[0].plot(fpr, tpr, color='#6366f1', lw=2.5, label=f'XGBoost  AUC = {auc_bin:.3f}')
axes[0].plot([0,1],[0,1], 'k--', lw=1, alpha=0.4, label='Random  AUC = 0.500')
axes[0].axhline(0.990, color='#ef4444', lw=1.5, ls=':', alpha=0.8, label='SOTA AUC = 0.990')
axes[0].axhline(0.916, color='#f59e0b', lw=1.5, ls=':', alpha=0.8, label='Baseline AUC = 0.916')
axes[0].set_xlabel('False Positive Rate'); axes[0].set_ylabel('True Positive Rate')
axes[0].set_title('ROC Curve', fontsize=12, fontweight='bold')
axes[0].legend(fontsize=9)
# ── Confusion Matrix ──────────────────────────────────────────────
cm     = confusion_matrix(yb_test, y_pred_bin)
cm_pct = cm.astype(float) / cm.sum(axis=1, keepdims=True) * 100
im     = axes[1].imshow(cm_pct, cmap='Blues', vmin=0, vmax=100)
plt.colorbar(im, ax=axes[1], label='% of true class')
axes[1].set_xticks([0,1]); axes[1].set_yticks([0,1])
axes[1].set_xticklabels(['Normal','Anomaly'])
axes[1].set_yticklabels(['Normal','Anomaly'])
axes[1].set_xlabel('Predicted'); axes[1].set_ylabel('True')
axes[1].set_title('Confusion Matrix', fontsize=12, fontweight='bold')
for i in range(2):
    for j in range(2):
        c = 'white' if cm_pct[i,j] > 55 else '#1e293b'
        axes[1].text(j, i, f'{cm[i,j]}\n({cm_pct[i,j]:.1f}%)',
                     ha='center', va='center', fontsize=11, fontweight='bold', color=c)
# ── Metric summary bars ───────────────────────────────────────────
metrics = {'ROC-AUC': auc_bin, 'F1-Score': f1_bin, 'Accuracy': acc_bin}
bars = axes[2].bar(metrics.keys(), metrics.values(),
                   color=['#6366f1','#8b5cf6','#a78bfa'],
                   edgecolor='white', linewidth=1.5, width=0.45)
axes[2].axhline(0.990, color='#ef4444', lw=1.5, ls='--', label='SOTA AUC (0.990)')
axes[2].axhline(0.916, color='#f59e0b', lw=1.5, ls='--', label='Baseline AUC (0.916)')
axes[2].set_ylim(0.5, 1.05)
axes[2].set_title('Performance Summary', fontsize=12, fontweight='bold')
axes[2].legend(fontsize=9)
for bar, val in zip(bars, metrics.values()):
    axes[2].text(bar.get_x() + bar.get_width()/2,
                 bar.get_height() + 0.008,
                 f'{val:.3f}', ha='center', fontsize=12, fontweight='bold', color='#1e293b')
plt.tight_layout()
plt.savefig('binary_results.png', dpi=120, bbox_inches='tight', facecolor='white')
plt.show()

Task 2 β€” Multi-class Classification: 19 Cell Types

Goal: Identify the exact cell type from morphological and clinical features alone.

Two models are trained and compared:

  • LightGBM β€” gradient boosting, fast, handles class imbalance natively with class_weight='balanced'
  • PyTorch MLP β€” 3-layer neural network with BatchNorm + Dropout, trained for 50 epochs with CosineAnnealingLR

Target: Macro-F1 > 0.85 across all 19 classes.

Task 2a β€” LightGBM

lgb_multi = lgb.LGBMClassifier(
    n_estimators=400,
    num_leaves=63,
    learning_rate=0.05,
    subsample=0.8,
    colsample_bytree=0.8,
    class_weight='balanced',
    random_state=42,
    verbose=-1,
)
lgb_multi.fit(X_train_sc, ym_train, eval_set=[(X_val_sc, ym_val)])
ym_pred_lgb = lgb_multi.predict(X_test_sc)
f1_lgb      = f1_score(ym_test, ym_pred_lgb, average='macro')
acc_lgb     = accuracy_score(ym_test, ym_pred_lgb)
print(f"LightGBM  Macro-F1: {f1_lgb:.4f}   Accuracy: {acc_lgb:.4f}")
print()
print(classification_report(ym_test, ym_pred_lgb, target_names=class_names))

Why num_leaves=63? In LightGBM, trees are grown leaf-wise rather than level-wise. num_leaves=63 allows for deeper, more expressive trees without the max_depth restriction XGBoost uses. For 19-class problems with subtle morphological differences between cell types, this expressive capacity matters. class_weight='balanced' automatically adjusts loss weights to compensate for rare cell types.

Task 2b β€” PyTorch MLP

Architecture: Input(N) β†’ Linear(256) β†’ BN β†’ ReLU β†’ Dropout(0.3) β†’ Linear(128) β†’ BN β†’ ReLU β†’ Dropout(0.2) β†’ Linear(64) β†’ ReLU β†’ Linear(19)

Training details:

  • Loss: CrossEntropyLoss with class weights (computed via compute_class_weight)
  • Optimizer: AdamW lr=1e-3, weight_decay=1e-4
  • Scheduler: CosineAnnealingLR over 50 epochs
  • Best model checkpoint saved by validation Macro-F1
class BloodCellMLP(nn.Module):
    def __init__(self, input_dim, num_classes):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(input_dim, 256), nn.BatchNorm1d(256), nn.ReLU(), nn.Dropout(0.3),
            nn.Linear(256, 128),       nn.BatchNorm1d(128), nn.ReLU(), nn.Dropout(0.2),
            nn.Linear(128, 64),        nn.ReLU(),
            nn.Linear(64, num_classes),
        )
    def forward(self, x):
        return self.net(x)
def make_loader(X, y, batch_size=64, shuffle=True):
    return DataLoader(
        TensorDataset(torch.tensor(X, dtype=torch.float32),
                      torch.tensor(y, dtype=torch.long)),
        batch_size=batch_size, shuffle=shuffle
    )
INPUT_DIM   = X_train_sc.shape[1]
NUM_CLASSES = len(class_names)
model       = BloodCellMLP(INPUT_DIM, NUM_CLASSES).to(DEVICE)
cw            = compute_class_weight('balanced', classes=np.unique(ym_train), y=ym_train)
class_weights = torch.tensor(cw, dtype=torch.float32).to(DEVICE)
criterion     = nn.CrossEntropyLoss(weight=class_weights)
optimizer     = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4)
scheduler     = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50)
total_params = sum(p.numel() for p in model.parameters())
print(f"Model parameters: {total_params:,}")
print(f"Input dim       : {INPUT_DIM}")
print(f"Output classes  : {NUM_CLASSES}")
print(model)

Architecture design choices explained:

  • BatchNorm1d after each Linear layer stabilizes training by normalizing activations β€” especially helpful when input features have very different scales despite StandardScaling.
  • Dropout(0.3) and Dropout(0.2) progressively decrease as the network narrows β€” more regularization where the representation is widest.
  • CosineAnnealingLR cycles the learning rate down smoothly, avoiding the abrupt loss plateaus that come with fixed learning rates.
  • AdamW over Adam adds proper weight decay decoupled from the gradient update β€” reducing overfitting on small tabular datasets.
train_loader = make_loader(X_train_sc, ym_train)
val_loader   = make_loader(X_val_sc,   ym_val,   shuffle=False)
history                    = {'train_loss': [], 'val_loss': [], 'val_f1': []}
best_f1, best_weights = 0, None
for epoch in range(1, 51):
    # ── train ────────────────────────────────────────────────────
    model.train()
    train_loss = 0.0
    for Xb, yb in train_loader:
        Xb, yb = Xb.to(DEVICE), yb.to(DEVICE)
        optimizer.zero_grad()
        loss = criterion(model(Xb), yb)
        loss.backward()
        optimizer.step()
        train_loss += loss.item()
    # ── validate ─────────────────────────────────────────────────
    model.eval()
    val_loss, preds, trues = 0.0, [], []
    with torch.no_grad():
        for Xb, yb in val_loader:
            Xb, yb = Xb.to(DEVICE), yb.to(DEVICE)
            out     = model(Xb)
            val_loss += criterion(out, yb).item()
            preds.extend(out.argmax(1).cpu().numpy())
            trues.extend(yb.cpu().numpy())
    val_f1 = f1_score(trues, preds, average='macro')
    scheduler.step()
    history['train_loss'].append(train_loss / len(train_loader))
    history['val_loss'].append(val_loss   / len(val_loader))
    history['val_f1'].append(val_f1)
    if val_f1 > best_f1:
        best_f1      = val_f1
        best_weights = {k: v.clone() for k, v in model.state_dict().items()}
    if epoch % 10 == 0:
        print(f"Epoch {epoch:3d}  "
              f"train_loss={history['train_loss'][-1]:.4f}  "
              f"val_loss={history['val_loss'][-1]:.4f}  "
              f"val_f1={val_f1:.4f}")
model.load_state_dict(best_weights)
print(f"\nBest Validation Macro-F1: {best_f1:.4f}")

Why save best weights by validation F1 and not by validation loss? Loss measures confidence calibration β€” F1 measures whether the model is actually classifying correctly across all classes. In imbalanced 19-class problems, loss can keep decreasing even as performance on rare classes deteriorates. Checkpointing by macro-F1 ensures we keep the model that best handles the hardest, rarest cells.

Task 2 β€” Results Visualization

# ── Test predictions ──────────────────────────────────────────────
model.eval()
with torch.no_grad():
    ym_pred_mlp = model(
        torch.tensor(X_test_sc, dtype=torch.float32).to(DEVICE)
    ).argmax(1).cpu().numpy()
f1_mlp  = f1_score(ym_test, ym_pred_mlp, average='macro')
acc_mlp = accuracy_score(ym_test, ym_pred_mlp)
fig = plt.figure(figsize=(20, 7))
fig.suptitle('Task 2 β€” Multi-class Classification Results (19 Cell Types)',
             fontsize=14, fontweight='bold')
# Left: training curves (loss + F1 on twin y-axes)
ax1 = fig.add_subplot(1, 3, 1)
ax1.plot(history['train_loss'], color='#f59e0b', lw=2, label='Train Loss')
ax1.plot(history['val_loss'],   color='#6366f1', lw=2, label='Val Loss')
ax1.set_xlabel('Epoch'); ax1.set_ylabel('Loss')
ax1.set_title('PyTorch MLP β€” Training Curves', fontsize=11, fontweight='bold')
ax1.legend(fontsize=9)
ax1b = ax1.twinx()
ax1b.plot(history['val_f1'], color='#10b981', lw=2, ls='--', label='Val Macro-F1')
ax1b.set_ylabel('Macro-F1', color='#10b981')
ax1b.tick_params(axis='y', labelcolor='#10b981')
ax1b.legend(loc='center right', fontsize=9)
# Middle: LightGBM confusion matrix
ax2 = fig.add_subplot(1, 3, 2)
cm_lgb     = confusion_matrix(ym_test, ym_pred_lgb)
short_names = [c.replace('_',' ')[:11] for c in class_names]
sns.heatmap(cm_lgb, annot=True, fmt='d', cmap='YlOrBr',
            linewidths=0.3, linecolor='white',
            xticklabels=short_names, yticklabels=short_names,
            ax=ax2, annot_kws={'size': 7})
ax2.set_title(f'LightGBM Confusion Matrix  (Macro-F1={f1_lgb:.3f})',
              fontsize=11, fontweight='bold')
# Right: per-class F1 comparison (LightGBM vs MLP)
ax3 = fig.add_subplot(1, 3, 3)
f1_per_lgb = f1_score(ym_test, ym_pred_lgb, average=None)
f1_per_mlp = f1_score(ym_test, ym_pred_mlp, average=None)
x = np.arange(len(class_names))
w = 0.38
ax3.barh(x - w/2, f1_per_lgb, w, label=f'LightGBM  macro={f1_lgb:.3f}', color='#f59e0b', alpha=0.85)
ax3.barh(x + w/2, f1_per_mlp, w, label=f'PyTorch MLP  macro={f1_mlp:.3f}', color='#6366f1', alpha=0.85)
ax3.set_yticks(x); ax3.set_yticklabels(short_names, fontsize=8)
ax3.set_xlabel('F1-Score'); ax3.set_xlim(0, 1.15)
ax3.set_title('Per-class F1 Comparison', fontsize=11, fontweight='bold')
ax3.legend(fontsize=8)
plt.tight_layout()
plt.savefig('multiclass_results.png', dpi=120, bbox_inches='tight', facecolor='white')
plt.show()
print(f"LightGBM   β€” Macro-F1: {f1_lgb:.4f}  |  Accuracy: {acc_lgb:.4f}")
print(f"PyTorch MLP β€” Macro-F1: {f1_mlp:.4f}  |  Accuracy: {acc_mlp:.4f}")

Task 3 β€” Disease-Level Prediction

Goal: Map individual cell observations to their associated clinical disease category.

This task is clinically the most important. A clinician cares more about β€œthis patient likely has Leukemia” than β€œthis cell is a Blast Cell.” The 6 disease categories correspond directly to treatment decisions.

Critical metric: Recall β€” missing a Leukemia cell is far more dangerous than a false alarm.

xgb_dis = xgb.XGBClassifier(
    n_estimators=300,
    max_depth=6,
    learning_rate=0.05,
    subsample=0.8,
    colsample_bytree=0.8,
    use_label_encoder=False,
    eval_metric='mlogloss',
    random_state=42,
    verbosity=0,
)
xgb_dis.fit(X_train_sc, yd_train,
            eval_set=[(X_val_sc, yd_val)], verbose=False)
yd_pred = xgb_dis.predict(X_test_sc)
f1_dis  = f1_score(yd_test, yd_pred, average='macro')
acc_dis = accuracy_score(yd_test, yd_pred)
print(f"Disease XGBoost  Macro-F1: {f1_dis:.4f}   Accuracy: {acc_dis:.4f}")
print()
print(classification_report(yd_test, yd_pred, target_names=disease_names))

Task 3 β€” Results Visualization

fig, axes = plt.subplots(1, 2, figsize=(14, 6))
fig.suptitle('Task 3 β€” Disease-Level Classification Results', fontsize=14, fontweight='bold')
# ── Confusion matrix ──────────────────────────────────────────────
cm_dis     = confusion_matrix(yd_test, yd_pred)
cm_dis_pct = cm_dis.astype(float) / cm_dis.sum(axis=1, keepdims=True) * 100
im = axes[0].imshow(cm_dis_pct, cmap='RdPu', vmin=0, vmax=100)
plt.colorbar(im, ax=axes[0], label='% of true class')
n = len(disease_names)
axes[0].set_xticks(range(n)); axes[0].set_yticks(range(n))
axes[0].set_xticklabels(disease_names, rotation=30, ha='right', fontsize=9)
axes[0].set_yticklabels(disease_names, fontsize=9)
axes[0].set_xlabel('Predicted'); axes[0].set_ylabel('True')
axes[0].set_title('Confusion Matrix', fontsize=12, fontweight='bold')
for i in range(n):
    for j in range(n):
        c = 'white' if cm_dis_pct[i,j] > 50 else '#1e293b'
        axes[0].text(j, i, f'{cm_dis[i,j]}\n{cm_dis_pct[i,j]:.0f}%',
                     ha='center', va='center', fontsize=8, fontweight='bold', color=c)
# ── Per-class recall ──────────────────────────────────────────────
recall_per = recall_score(yd_test, yd_pred, average=None)
pal = ['#e11d48','#f97316','#8b5cf6','#06b6d4','#22c55e','#94a3b8']
axes[1].barh(disease_names, recall_per, color=pal[:n], edgecolor='white', linewidth=1.5)
axes[1].axvline(0.90, color='#1e293b', lw=1.5, ls='--', alpha=0.6, label='Target recall = 0.90')
axes[1].set_xlabel('Recall'); axes[1].set_xlim(0, 1.15)
axes[1].set_title('Per-class Recall\n(clinically critical metric)', fontsize=12, fontweight='bold')
axes[1].legend(fontsize=9)
for i, v in enumerate(recall_per):
    axes[1].text(v + 0.01, i, f'{v:.3f}', va='center',
                 fontsize=10, fontweight='bold', color='#1e293b')
plt.tight_layout()
plt.savefig('disease_results.png', dpi=120, bbox_inches='tight', facecolor='white')
plt.show()

Step 5 β€” SHAP Explainability

SHAP (SHapley Additive exPlanations) explains why the model made each prediction by assigning an importance value to every feature for every individual sample. This is not just a nice-to-have in medical AI β€” it is a clinical requirement.

How to read the summary plot:

  • Each row = one feature
  • Each dot = one test sample
  • X position = impact on model output (left = pushes toward Normal, right = toward Anomaly)
  • Color = feature value (red = high, blue = low)
  • Wider spread = more influential feature
explainer   = shap.TreeExplainer(xgb_bin)
shap_values = explainer.shap_values(X_test_sc[:300])
fig, axes = plt.subplots(1, 2, figsize=(18, 7))
fig.suptitle('SHAP Feature Importance β€” Binary Task (Normal vs Anomaly)',
             fontsize=14, fontweight='bold')
# Beeswarm: direction and magnitude for each sample
plt.sca(axes[0])
shap.summary_plot(shap_values, X_test_sc[:300],
                  feature_names=feature_names,
                  max_display=15, plot_size=None, show=False)
axes[0].set_title('SHAP Beeswarm β€” feature impact direction',
                  fontsize=11, fontweight='bold')
# Bar: mean |SHAP| β€” overall importance ranking
plt.sca(axes[1])
shap.summary_plot(shap_values, X_test_sc[:300],
                  feature_names=feature_names,
                  max_display=15, plot_type='bar',
                  plot_size=None, show=False)
axes[1].set_title('Mean |SHAP value| β€” overall importance',
                  fontsize=11, fontweight='bold')
plt.tight_layout()
plt.savefig('shap_analysis.png', dpi=120, bbox_inches='tight', facecolor='white')
plt.show()

Why TreeExplainer specifically? SHAP has different explainers optimized for different model families. TreeExplainer computes exact Shapley values for tree-based models (XGBoost, LightGBM, Random Forest) in polynomial time by exploiting the tree structure β€” no approximation needed. KernelExplainer works on any model but is much slower and approximate.

Step 6 β€” Benchmark Comparison

results = pd.DataFrame({
    'Model':  ['CytoDiffusion (SOTA)', 'Baseline (paper)',
               'XGBoost  (ours)', 'LightGBM (ours)', 'PyTorch MLP (ours)'],
    'Task':   ['Binary', 'Binary', 'Binary', 'Multi-class', 'Multi-class'],
    'Metric': ['AUC',    'AUC',    'AUC',    'Macro-F1',   'Macro-F1'],
    'Score':  [0.990,     0.916,   auc_bin,  f1_lgb,       f1_mlp],
    'Source': ['Paper', 'Paper', 'Ours', 'Ours', 'Ours'],
})
print(results.to_string(index=False))

Important context: CytoDiffusion uses raw microscopy images plus a generative diffusion model. We use only 36 tabular features. The gap is expected and instructive β€” it quantifies exactly how much information lives in the raw pixel data vs the extracted morphological features.

Model Approach Binary AUC Notes CytoDiffusion Vision + Generative AI (SOTA) 0.990 Nature MI 2025 Baseline Paper’s tabular baseline 0.916 From the paper Our XGBoost Tabular only, no leakage see output

Enhancement 1 β€” Feature Correlation & Redundant Column Removal

Features with correlation > 0.95 carry nearly identical information. Keeping both adds noise and slows training without improving predictions.

corr_matrix = np.corrcoef(X_train_sc.T)
threshold = 0.95
to_drop   = set()
for i in range(len(feature_names)):
    for j in range(i + 1, len(feature_names)):
        if abs(corr_matrix[i, j]) > threshold:
            print(f"  HIGH CORR ({corr_matrix[i,j]:.3f}): "
                  f"{feature_names[i]}  ↔  {feature_names[j]}")
            to_drop.add(feature_names[j])
print(f"\nColumns to drop (|r| > {threshold}): {list(to_drop) if to_drop else 'None'}")
if to_drop:
    keep_idx       = [i for i, f in enumerate(feature_names) if f not in to_drop]
    X_train_sc     = X_train_sc[:, keep_idx]
    X_val_sc       = X_val_sc[:,   keep_idx]
    X_test_sc      = X_test_sc[:,  keep_idx]
    X_train_sm     = X_train_sm[:, keep_idx]
    feature_names  = [feature_names[i] for i in keep_idx]
    print(f"Features after removal: {len(feature_names)}")
else:
    print("No redundant features found β€” all 29 kept.")

Enhancement 2 β€” Stratified K-Fold Cross-Validation

A single train/test split can be unlucky. 5-fold stratified CV gives a reliable mean Β± std estimate of true generalization performance across the whole dataset.

from sklearn.model_selection import StratifiedKFold, cross_validate
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
cv_bin = cross_validate(
    xgb.XGBClassifier(n_estimators=300, max_depth=6, learning_rate=0.05,
                       subsample=0.8, colsample_bytree=0.8,
                       use_label_encoder=False, eval_metric='logloss',
                       random_state=42, verbosity=0),
    X, y_binary,
    cv=skf, scoring=['roc_auc', 'f1'],
    n_jobs=-1, verbose=0
)
cv_multi = cross_validate(
    lgb.LGBMClassifier(n_estimators=300, num_leaves=63, learning_rate=0.05,
                        class_weight='balanced', random_state=42, verbose=-1),
    X, y_multi,
    cv=skf, scoring={'f1_macro': 'f1_macro'},
    n_jobs=-1, verbose=0
)
cv_dis = cross_validate(
    xgb.XGBClassifier(n_estimators=300, max_depth=6, learning_rate=0.05,
                       use_label_encoder=False, eval_metric='mlogloss',
                       random_state=42, verbosity=0),
    X, y_disease,
    cv=skf, scoring={'f1_macro': 'f1_macro'},
    n_jobs=-1, verbose=0
)
print(f"Binary AUC     : {cv_bin['test_roc_auc'].mean():.4f} Β± {cv_bin['test_roc_auc'].std():.4f}")
print(f"Multi-class F1 : {cv_multi['test_f1_macro'].mean():.4f} Β± {cv_multi['test_f1_macro'].std():.4f}")
print(f"Disease F1     : {cv_dis['test_f1_macro'].mean():.4f} Β± {cv_dis['test_f1_macro'].std():.4f}")

Enhancement 3 β€” Optuna Hyperparameter Tuning

Bayesian optimization (TPE sampler) searches the hyperparameter space far more efficiently than grid or random search. 50 trials here β€” increase n_trials for further gains.

import optuna
optuna.logging.set_verbosity(optuna.logging.WARNING)
def objective(trial):
    params = {
        'n_estimators':     trial.suggest_int('n_estimators', 100, 500),
        'max_depth':        trial.suggest_int('max_depth', 3, 10),
        'learning_rate':    trial.suggest_float('learning_rate', 0.01, 0.3, log=True),
        'subsample':        trial.suggest_float('subsample', 0.5, 1.0),
        'colsample_bytree': trial.suggest_float('colsample_bytree', 0.5, 1.0),
        'min_child_weight': trial.suggest_int('min_child_weight', 1, 10),
        'use_label_encoder': False,
        'eval_metric': 'logloss',
        'random_state': 42,
        'verbosity': 0,
    }
    model = xgb.XGBClassifier(**params)
    model.fit(X_train_sm, yb_train_sm,
              eval_set=[(X_val_sc, yb_val)], verbose=False)
    return roc_auc_score(yb_val, model.predict_proba(X_val_sc)[:, 1])
study = optuna.create_study(direction='maximize',
                             sampler=optuna.samplers.TPESampler(seed=42))
study.optimize(objective, n_trials=50, show_progress_bar=True)
print(f"\nBest AUC (val): {study.best_value:.4f}")
print(f"Best params   : {study.best_params}")
best_params = study.best_params | {'use_label_encoder': False,
                                    'eval_metric': 'logloss',
                                    'random_state': 42, 'verbosity': 0}
xgb_tuned = xgb.XGBClassifier(**best_params)
xgb_tuned.fit(X_train_sm, yb_train_sm,
              eval_set=[(X_val_sc, yb_val)], verbose=False)
auc_tuned = roc_auc_score(yb_test, xgb_tuned.predict_proba(X_test_sc)[:, 1])
print(f"\nTuned XGBoost Test AUC : {auc_tuned:.4f}")
print(f"Default XGBoost Test AUC: {auc_bin:.4f}")
print(f"Improvement             : +{auc_tuned - auc_bin:.4f}")

Why Bayesian optimization over grid search? Grid search with 6 hyperparameters and 5 values each requires ⁡⁢ = 15,625 evaluations. Optuna’s TPE (Tree-structured Parzen Estimator) builds a probabilistic model of which regions of the search space produce good results, then samples more from those regions. 50 well-chosen trials often outperform hundreds of random ones.

Enhancement 4 β€” Ensemble: Soft Voting (XGBoost + LightGBM)

Each model produces class probabilities. Averaging them reduces variance and typically improves over any single model, because different model families make different types of errors.

proba_xgb = xgb_tuned.predict_proba(X_test_sc)
lgb_bin = lgb.LGBMClassifier(
    class_weight='balanced', random_state=42, verbose=-1
).fit(X_train_sc, yb_train)
proba_lgb = lgb_bin.predict_proba(X_test_sc)
proba_ensemble_bin = (proba_xgb[:, 1] + proba_lgb[:, 1]) / 2
auc_ensemble       = roc_auc_score(yb_test, proba_ensemble_bin)
f1_ensemble        = f1_score(yb_test, (proba_ensemble_bin >= 0.5).astype(int))
xgb_multi_new = xgb.XGBClassifier(
    n_estimators=300, max_depth=6, learning_rate=0.05,
    subsample=0.8, colsample_bytree=0.8,
    use_label_encoder=False, eval_metric='mlogloss',
    random_state=42, verbosity=0
).fit(X_train_sc, ym_train)
lgb_multi_new = lgb.LGBMClassifier(
    n_estimators=400, num_leaves=63, learning_rate=0.05,
    subsample=0.8, colsample_bytree=0.8,
    class_weight='balanced', random_state=42, verbose=-1
).fit(X_train_sc, ym_train)
proba_ensemble_multi = (xgb_multi_new.predict_proba(X_test_sc) +
                        lgb_multi_new.predict_proba(X_test_sc)) / 2
ym_pred_ensemble     = proba_ensemble_multi.argmax(axis=1)
f1_ensemble_multi    = f1_score(ym_test, ym_pred_ensemble, average='macro')
print(f"Ensemble Binary AUC     : {auc_ensemble:.4f}  (XGBoost alone: {auc_bin:.4f})")
print(f"Ensemble Multi-class F1 : {f1_ensemble_multi:.4f}  (LightGBM alone: {f1_lgb:.4f})")

Enhancement 5 β€” Hierarchical Classifier

A two-stage pipeline: Stage 1 filters out Normal cells with the binary model, Stage 2 runs the full 19-class model only on predicted Anomaly samples. This reduces confusion between Normal and rare Anomaly subtypes.

stage1_pred  = xgb_tuned.predict(X_test_sc)
normal_mask  = stage1_pred == 0
anomaly_mask = stage1_pred == 1
lgb_hier = lgb.LGBMClassifier(
    n_estimators=400, num_leaves=63, learning_rate=0.05,
    subsample=0.8, colsample_bytree=0.8,
    class_weight='balanced', random_state=42, verbose=-1
).fit(X_train_sc, ym_train)
ym_pred_hier = np.full(len(ym_test), -1, dtype=int)
normal_label_ids = [i for i, n in enumerate(class_names) if n in NORMAL_TYPES]
ym_pred_hier[normal_mask] = normal_label_ids[0]
if anomaly_mask.sum() > 0:
    ym_pred_hier[anomaly_mask] = lgb_hier.predict(X_test_sc[anomaly_mask])
valid_mask = ym_pred_hier != -1
f1_hier    = f1_score(ym_test[valid_mask], ym_pred_hier[valid_mask], average='macro')
acc_hier   = accuracy_score(ym_test[valid_mask], ym_pred_hier[valid_mask])
print(f"\nHierarchical Macro-F1 : {f1_hier:.4f}")
print(f"Hierarchical Accuracy : {acc_hier:.4f}")
print(f"Flat LightGBM F1      : {f1_lgb:.4f}")

Enhancement 6 β€” Error Analysis

Which cell types are most often confused? Examining misclassified samples reveals exactly where the model struggles and guides future feature engineering.

wrong_mask = ym_pred_lgb != ym_test
wrong_true = [class_names[i] for i in ym_test[wrong_mask]]
wrong_pred = [class_names[i] for i in ym_pred_lgb[wrong_mask]]
error_df = pd.DataFrame({'true': wrong_true, 'predicted': wrong_pred})
error_pairs = (error_df.groupby(['true','predicted'])
                        .size()
                        .reset_index(name='count')
                        .sort_values('count', ascending=False))
print(f"Total misclassified: {wrong_mask.sum()} / {len(ym_test)}")
print(f"Error rate         : {wrong_mask.mean()*100:.2f}%\n")
print("Top confused pairs:")
print(error_pairs.head(15).to_string(index=False))
# Per-class error rate colored by severity
per_class_err = []
for i, name in enumerate(class_names):
    mask = ym_test == i
    if mask.sum() > 0:
        err = (ym_pred_lgb[mask] != i).mean()
        per_class_err.append((name, err, mask.sum()))
err_df = pd.DataFrame(per_class_err, columns=['class','error_rate','support'])
err_df = err_df.sort_values('error_rate', ascending=True)
# Color coding: 🟒 <5% | 🟑 5–10% | πŸ”΄ >10%
colors_err = ['#ef4444' if e > 0.1 else '#f59e0b' if e > 0.05 else '#22c55e'
              for e in err_df['error_rate']]

Enhancement 7 β€” Calibration Curve

A well-calibrated model means: when it predicts β€œ70% probability of Anomaly”, it should be correct approximately 70% of the time. Poor calibration = overconfident or underconfident predictions, which is especially dangerous in clinical decision support.

from sklearn.calibration import calibration_curve, CalibratedClassifierCV
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
for name, proba, color in [
    ('XGBoost (default)',  y_proba_bin, '#6366f1'),
    ('XGBoost (tuned)',    xgb_tuned.predict_proba(X_test_sc)[:,1], '#8b5cf6'),
    ('LightGBM',          lgb.LGBMClassifier(class_weight='balanced',
                              random_state=42, verbose=-1
                          ).fit(X_train_sc, yb_train).predict_proba(X_test_sc)[:,1], '#f59e0b'),
]:
    frac_pos, mean_pred = calibration_curve(yb_test, proba, n_bins=10)
    axes[0].plot(mean_pred, frac_pos, 'o-', lw=2, ms=6, color=color, label=name)
axes[0].plot([0,1],[0,1], 'k--', lw=1.5, alpha=0.5, label='Perfect calibration')
axes[0].set_xlabel('Mean Predicted Probability')
axes[0].set_ylabel('Fraction of Positives')
axes[0].set_title('Calibration Curves\n(closer to diagonal = better)', fontsize=11, fontweight='bold')
axes[0].legend(fontsize=9)
# Platt scaling to fix calibration
xgb_calibrated = CalibratedClassifierCV(xgb_bin, cv=5, method='sigmoid')
xgb_calibrated.fit(X_train_sc, yb_train)
auc_cal = roc_auc_score(yb_test, xgb_calibrated.predict_proba(X_test_sc)[:,1])
print(f"Original XGBoost AUC    : {auc_bin:.4f}")
print(f"Calibrated XGBoost AUC  : {auc_cal:.4f}")

Platt scaling (method='sigmoid') fits a logistic regression on top of the model's raw scores to map them to well-calibrated probabilities. cv=5 cross-validation prevents overfitting the calibration step itself.

Enhancement 8 β€” Learning Curve

How much data does the model actually need? If the validation score keeps rising at full training set size, collecting more samples would help. If it has plateaued, focus on better features instead.

from sklearn.model_selection import learning_curve
train_sizes, train_scores, val_scores = learning_curve(
    xgb.XGBClassifier(n_estimators=200, max_depth=6, learning_rate=0.05,
                       use_label_encoder=False, eval_metric='logloss',
                       random_state=42, verbosity=0),
    X, y_binary,
    cv=StratifiedKFold(n_splits=5, shuffle=True, random_state=42),
    train_sizes=np.linspace(0.1, 1.0, 10),
    scoring='roc_auc',
    n_jobs=-1,
)
train_mean = train_scores.mean(axis=1)
val_mean   = val_scores.mean(axis=1)
last_gain = val_mean[-1] - val_mean[-2]
print(f"Val AUC at 90% data : {val_mean[-2]:.4f}")
print(f"Val AUC at 100% data: {val_mean[-1]:.4f}")
print(f"Last gain           : +{last_gain:.4f}")
if last_gain < 0.002:
    print("β†’ Curve has plateaued β€” more data unlikely to help significantly.")
else:
    print("β†’ Curve still rising β€” collecting more data could improve performance.")

The gap between train and validation AUC (the β€œoverfitting gap”) is plotted as a bar chart. Red bars (gap > 5%) indicate training set sizes where the model overfits β€” important to know before deciding how much regularization to apply.

Final Results Summary

print("=" * 55)
print("  FINAL RESULTS SUMMARY")
print("=" * 55)
print(f"  Task 1  Binary XGBoost        AUC      : {auc_bin:.4f}")
print(f"  Task 1  Binary XGBoost        F1       : {f1_bin:.4f}")
print(f"  Task 2  Multi-class LightGBM  Macro-F1 : {f1_lgb:.4f}")
print(f"  Task 2  Multi-class MLP       Macro-F1 : {f1_mlp:.4f}")
print(f"  Task 3  Disease XGBoost       Macro-F1 : {f1_dis:.4f}")
print("-" * 55)
print(f"  SOTA CytoDiffusion (image)    AUC      : 0.9900")
print(f"  Baseline (paper, tabular)     AUC      : 0.9160")
print("=" * 55)

Key Takeaways

1. Data leakage is the #1 pitfall in medical ML. Always audit every feature for any column derived from or correlated with the target. AUC = 1.000 on a real dataset is almost never signal β€” it’s almost always leakage.

2. Tabular-only models can approach (but not match) image-based SOTA. Our XGBoost/LightGBM models reach AUC β‰ˆ 0.88–0.93 vs the paper’s 0.990 with full image data. The gap quantifies exactly how much discriminative information lives in the raw pixels vs extracted features.

3. Choose your evaluation metric based on clinical stakes. Accuracy is misleading on imbalanced classes. For disease detection, recall (sensitivity) is the primary metric β€” a missed Leukemia cell is orders of magnitude more costly than a false alarm.

4. Explainability is not optional in medical AI. SHAP doesn’t just help you understand the model β€” it helps clinicians trust it. Any deployment-grade medical ML system should include feature-level explanations for every prediction.

5. The enhancement stack compounds. Individually, each enhancement (CV, Optuna tuning, ensembling, hierarchical classification) adds 1–3% improvement. Combined, they add up to a meaningfully stronger system than any single technique alone.

Suggested Next Steps

  • Feature engineering from clinical knowledge β€” combine morphological features into clinically meaningful ratios (e.g., nucleus-to-cytoplasm ratio = nucleus_area / cell_area)
  • Deeper MLP with residual connections β€” skip connections prevent gradient vanishing in deeper networks and often help on tabular data
  • Transformer-based tabular model β€” TabTransformer or FT-Transformer treat each feature as a token; they can model feature interactions that tree-based models miss
  • Uncertainty quantification β€” use Monte Carlo Dropout or conformal prediction to produce calibrated confidence intervals on predictions, not just point estimates

메타데이터
post_id
4eacef2bfb5c
slug
blood-cell-anomaly-detection-a-complete-tabular-ml-pipeline-4eacef2bfb5c
url
https://medium.com/@goktani/blood-cell-anomaly-detection-a-complete-tabular-ml-pipeline-4eacef2bfb5c
canonical_url
https://medium.com/@goktani/blood-cell-anomaly-detection-a-complete-tabular-ml-pipeline-4eacef2bfb5c
author_url
https://medium.com/@goktani
status
ok
fetched_at
2026-07-11 22:16:18