π©Έ 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
π©Έ 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?
xgboostandlightgbmβ 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
- Encode categorical columns with
LabelEncoder - Build 3 target vectors β binary, 19-class, and disease-level
- Drop leaked AI score columns from the feature matrix
- Split 70% train / 15% validation / 15% test (stratified)
- Scale with
StandardScaler(fit on train only β no leakage) - 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_multiensures 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
StandardScaleron the full dataset, validation and test statistics bleed into training. Alwaysfit_transformon train, then onlytransformon 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_splitcalls: 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:
CrossEntropyLosswith class weights (computed viacompute_class_weight) - Optimizer:
AdamWlr=1e-3, weight_decay=1e-4 - Scheduler:
CosineAnnealingLRover 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:
BatchNorm1dafter eachLinearlayer stabilizes training by normalizing activations β especially helpful when input features have very different scales despite StandardScaling.Dropout(0.3)andDropout(0.2)progressively decrease as the network narrows β more regularization where the representation is widest.CosineAnnealingLRcycles the learning rate down smoothly, avoiding the abrupt loss plateaus that come with fixed learning rates.AdamWoverAdamadds 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 β
TabTransformerorFT-Transformertreat 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