|
| 1 | +# Donor-Level Train/Test Split Implementation |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +Modified the preprocessing pipeline to implement **donor-level train/test splitting** instead of cell-level splitting. This prevents data leakage of subject-specific signatures that could cause models to learn how to identify subjects rather than predict age. |
| 6 | + |
| 7 | +## Problem Statement |
| 8 | + |
| 9 | +Previously, the train/test split was performed at the **cell level** with age stratification. This meant that cells from the same donor could appear in both train and test sets. This creates **data leakage** because: |
| 10 | + |
| 11 | +1. Each donor has unique biological signatures (genetics, environment, lifestyle, etc.) |
| 12 | +2. The model can learn these donor-specific patterns |
| 13 | +3. During evaluation, the model might recognize cells from the same donor seen during training |
| 14 | +4. This leads to inflated performance metrics that don't reflect true generalization |
| 15 | + |
| 16 | +## Solution |
| 17 | + |
| 18 | +The new implementation performs **donor-level stratified splitting**: |
| 19 | + |
| 20 | +1. **Group cells by donor** (using `donor_id`, `donor`, `subject_id`, or `individual` columns) |
| 21 | +2. **Compute representative age** for each donor (median age of their cells) |
| 22 | +3. **Split donors** (not cells) into train/test using stratified sampling by donor age |
| 23 | +4. **Assign all cells** from each donor to the same split (train OR test, never both) |
| 24 | + |
| 25 | +### Key Features |
| 26 | + |
| 27 | +- **Donor-level stratification**: Maintains age distribution at the donor level |
| 28 | +- **Automatic fallback**: Falls back to cell-level split if no donor column is found |
| 29 | +- **Multiple donor column support**: Tries `donor_id`, `donor`, `subject_id`, `individual` |
| 30 | +- **Comprehensive logging**: Logs donor statistics and split quality |
| 31 | +- **Memory efficient**: Uses Polars lazy API and streaming where possible |
| 32 | + |
| 33 | +## Files Modified |
| 34 | + |
| 35 | +### 1. `src/cell2sentence4longevity/preprocessing/h5ad_converter.py` |
| 36 | + |
| 37 | +**Function**: `convert_h5ad_to_train_test()` |
| 38 | + |
| 39 | +**Changes**: |
| 40 | +- Added donor column detection logic (lines 1342-1343) |
| 41 | +- Implemented donor-level stratified split (lines 1383-1442) |
| 42 | +- Computes median age per donor for stratification (lines 1395-1402) |
| 43 | +- Uses sklearn stratified split on donors, then joins back to cells (lines 1412-1432) |
| 44 | +- Falls back to cell-level split if no donor column found (lines 1345-1382) |
| 45 | +- Added comprehensive logging for donor statistics |
| 46 | + |
| 47 | +**Key Logic**: |
| 48 | +```python |
| 49 | +# Identify donor column |
| 50 | +donor_cols = ['donor_id', 'donor', 'subject_id', 'individual'] |
| 51 | +donor_col = next((col for col in donor_cols if col in chunk_df.columns), None) |
| 52 | + |
| 53 | +if donor_col: |
| 54 | + # Donor-level split |
| 55 | + donor_ages = chunk_df.group_by(donor_col).agg([ |
| 56 | + pl.col('age').median().alias('donor_age') |
| 57 | + ]) |
| 58 | + |
| 59 | + # Stratify donors by age |
| 60 | + train_donors = stratified_split_of_donors(...) |
| 61 | + test_donors = ... |
| 62 | + |
| 63 | + # Join back to get all cells for each donor |
| 64 | + train_chunk = chunk_df.join(train_donors, on=donor_col, how='inner') |
| 65 | + test_chunk = chunk_df.join(test_donors, on=donor_col, how='inner') |
| 66 | +``` |
| 67 | + |
| 68 | +### 2. `src/cell2sentence4longevity/preprocessing/train_test_split.py` |
| 69 | + |
| 70 | +**Function**: `create_train_test_split()` |
| 71 | + |
| 72 | +**Changes**: |
| 73 | +- Updated docstring to reflect donor-level splitting (lines 25-29) |
| 74 | +- Added donor column detection for lazy datasets (lines 168-203) |
| 75 | +- Implemented donor-level stratified split using sklearn (lines 212-272) |
| 76 | +- Computes donor statistics (total donors, cells per donor) (lines 189-203) |
| 77 | +- Uses sklearn's `train_test_split` with donor age stratification (lines 240-265) |
| 78 | +- Filters lazy dataset using `is_in` with donor lists (lines 268-272) |
| 79 | +- Falls back to cell-level split if no donor column (lines 274-287) |
| 80 | + |
| 81 | +**Key Logic**: |
| 82 | +```python |
| 83 | +# Compute representative age for each donor |
| 84 | +donor_ages = ( |
| 85 | + lazy_dataset |
| 86 | + .group_by(donor_col) |
| 87 | + .agg([ |
| 88 | + pl.col('age').median().alias('donor_age'), |
| 89 | + pl.len().alias('cell_count') |
| 90 | + ]) |
| 91 | +).collect() |
| 92 | + |
| 93 | +# Stratified split of donors by age |
| 94 | +train_donor_ids, test_donor_ids = sklearn_split( |
| 95 | + donor_ids_array, |
| 96 | + test_size=test_size, |
| 97 | + random_state=random_state, |
| 98 | + stratify=np.round(donor_ages_array, 1) |
| 99 | +) |
| 100 | + |
| 101 | +# Filter cells by donor assignment |
| 102 | +lazy_train = lazy_dataset.filter(pl.col(donor_col).is_in(train_donor_series)) |
| 103 | +lazy_test = lazy_dataset.filter(pl.col(donor_col).is_in(test_donor_series)) |
| 104 | +``` |
| 105 | + |
| 106 | +### 3. `tests/test_donor_split.py` (New File) |
| 107 | + |
| 108 | +**Purpose**: Comprehensive tests for donor-level split validation |
| 109 | + |
| 110 | +**Tests**: |
| 111 | +1. **`test_donor_split_no_leakage()`**: Verifies no donor appears in both train and test |
| 112 | + - Creates synthetic data with 10 donors, 50 cells each |
| 113 | + - Runs donor-level split |
| 114 | + - Validates no overlap between train and test donors |
| 115 | + - Verifies all cells from same donor are in same split |
| 116 | + |
| 117 | +2. **`test_fallback_to_cell_level_split_without_donor_column()`**: Verifies fallback behavior |
| 118 | + - Creates data without donor column |
| 119 | + - Runs split |
| 120 | + - Validates cell-level split works correctly |
| 121 | + |
| 122 | +## Benefits |
| 123 | + |
| 124 | +1. **Prevents data leakage**: No donor-specific signatures can be learned and exploited |
| 125 | +2. **True generalization**: Model must learn age-related patterns, not donor identification |
| 126 | +3. **Better evaluation**: Test performance reflects true ability to predict age for unseen subjects |
| 127 | +4. **Maintains age distribution**: Stratification ensures similar age distributions in train/test |
| 128 | +5. **Robust fallback**: Automatically handles datasets without donor information |
| 129 | +6. **Production ready**: Comprehensive logging and error handling |
| 130 | + |
| 131 | +## Usage |
| 132 | + |
| 133 | +No changes required to existing code! The donor-level split is **automatically applied** when: |
| 134 | +- A donor column exists (`donor_id`, `donor`, `subject_id`, or `individual`) |
| 135 | +- Age stratification is enabled (default: `stratify_by_age=True`) |
| 136 | + |
| 137 | +If no donor column is found, it gracefully falls back to cell-level split with a warning. |
| 138 | + |
| 139 | +### Example |
| 140 | + |
| 141 | +```python |
| 142 | +from cell2sentence4longevity.preprocessing import convert_h5ad_to_train_test |
| 143 | + |
| 144 | +# Automatically uses donor-level split if donor column exists |
| 145 | +convert_h5ad_to_train_test( |
| 146 | + h5ad_path=Path("data.h5ad"), |
| 147 | + output_dir=Path("output"), |
| 148 | + test_size=0.05, |
| 149 | + stratify_by_age=True, # Stratifies at donor level! |
| 150 | + random_state=42 |
| 151 | +) |
| 152 | +``` |
| 153 | + |
| 154 | +## Validation |
| 155 | + |
| 156 | +Run the tests to verify donor-level split works correctly: |
| 157 | + |
| 158 | +```bash |
| 159 | +uv run pytest tests/test_donor_split.py -v |
| 160 | +``` |
| 161 | + |
| 162 | +Expected output: |
| 163 | +``` |
| 164 | +✓ Donor-level split validation passed: |
| 165 | + - Train donors: 8 |
| 166 | + - Test donors: 2 |
| 167 | + - Train cells: 400 |
| 168 | + - Test cells: 100 |
| 169 | + - No donor leakage detected! |
| 170 | +``` |
| 171 | + |
| 172 | +## Logging |
| 173 | + |
| 174 | +The implementation logs comprehensive donor statistics: |
| 175 | + |
| 176 | +```json |
| 177 | +{ |
| 178 | + "message_type": "using_donor_level_split", |
| 179 | + "donor_col": "donor_id", |
| 180 | + "unique_donors_in_chunk": 47 |
| 181 | +} |
| 182 | + |
| 183 | +{ |
| 184 | + "message_type": "donor_statistics", |
| 185 | + "total_donors": 150, |
| 186 | + "total_cells": 45000, |
| 187 | + "avg_cells_per_donor": 300.0 |
| 188 | +} |
| 189 | + |
| 190 | +{ |
| 191 | + "message_type": "donor_split_complete", |
| 192 | + "train_donors": 142, |
| 193 | + "test_donors": 8 |
| 194 | +} |
| 195 | +``` |
| 196 | + |
| 197 | +## Future Improvements |
| 198 | + |
| 199 | +Potential enhancements (not implemented yet): |
| 200 | + |
| 201 | +1. **Multiple stratification factors**: Stratify by both age and tissue type |
| 202 | +2. **Minimum donor requirements**: Ensure test set has minimum number of donors per age bin |
| 203 | +3. **Donor metadata export**: Export donor-level statistics for analysis |
| 204 | +4. **Cross-validation support**: K-fold donor-level cross-validation |
| 205 | + |
| 206 | +## References |
| 207 | + |
| 208 | +- Original issue: "cells from the same donor do not appear in both train and test" |
| 209 | +- Related concept: Group-aware splitting in machine learning |
| 210 | +- Similar to: Patient-level splitting in medical ML, speaker-level splitting in audio ML |
| 211 | + |
0 commit comments