Skip to content

Commit d57a5e3

Browse files
committed
added some tests
1 parent 72425dd commit d57a5e3

2 files changed

Lines changed: 386 additions & 0 deletions

File tree

docs/DONOR_LEVEL_SPLIT.md

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
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+

tests/test_donor_split.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
"""Tests for donor-level train/test split to prevent data leakage."""
2+
3+
import tempfile
4+
from pathlib import Path
5+
6+
import numpy as np
7+
import polars as pl
8+
import pytest
9+
10+
11+
def test_donor_split_no_leakage():
12+
"""Test that donor-level split ensures no donor appears in both train and test."""
13+
14+
# Create synthetic test data with multiple donors and cells
15+
np.random.seed(42)
16+
n_donors = 10
17+
cells_per_donor = 50
18+
19+
# Generate donor data
20+
donors = [f"donor_{i}" for i in range(n_donors)]
21+
donor_ages = np.random.uniform(20, 80, n_donors)
22+
23+
# Generate cell data
24+
data = []
25+
for donor_id, donor_age in zip(donors, donor_ages):
26+
for _ in range(cells_per_donor):
27+
# Add some variation in cell age (but centered on donor age)
28+
cell_age = donor_age + np.random.normal(0, 0.1)
29+
data.append({
30+
'donor_id': donor_id,
31+
'age': cell_age,
32+
'gene_sentence_2000': 'gene1 gene2 gene3',
33+
'cell_type': f'type_{np.random.randint(1, 5)}'
34+
})
35+
36+
df = pl.DataFrame(data)
37+
38+
# Write to temporary parquet files (simulating input chunks)
39+
with tempfile.TemporaryDirectory() as tmpdir:
40+
chunks_dir = Path(tmpdir) / 'chunks'
41+
chunks_dir.mkdir(parents=True, exist_ok=True)
42+
43+
# Write in chunks
44+
chunk_size = 100
45+
n_chunks = (len(df) + chunk_size - 1) // chunk_size
46+
for i in range(n_chunks):
47+
start_idx = i * chunk_size
48+
end_idx = min(start_idx + chunk_size, len(df))
49+
chunk = df.slice(start_idx, end_idx - start_idx)
50+
chunk.write_parquet(chunks_dir / f"chunk_{i:04d}.parquet")
51+
52+
# Run the donor-level split using our modified function
53+
from cell2sentence4longevity.preprocessing.train_test_split import create_train_test_split
54+
55+
output_dir = Path(tmpdir) / 'output'
56+
create_train_test_split(
57+
parquet_dir=chunks_dir,
58+
output_dir=output_dir,
59+
dataset_name='test_dataset',
60+
test_size=0.2,
61+
random_state=42,
62+
chunk_size=100,
63+
compression='zstd',
64+
compression_level=3,
65+
use_pyarrow=True
66+
)
67+
68+
# Load train and test sets
69+
train_dir = output_dir / 'test_dataset' / 'train'
70+
test_dir = output_dir / 'test_dataset' / 'test'
71+
72+
train_df = pl.scan_parquet(train_dir / '*.parquet').collect()
73+
test_df = pl.scan_parquet(test_dir / '*.parquet').collect()
74+
75+
# Verify no donor appears in both train and test
76+
train_donors = set(train_df['donor_id'].unique().to_list())
77+
test_donors = set(test_df['donor_id'].unique().to_list())
78+
79+
overlap = train_donors & test_donors
80+
assert len(overlap) == 0, f"Found {len(overlap)} donors in both train and test: {overlap}"
81+
82+
# Verify all donors are accounted for
83+
all_original_donors = set(df['donor_id'].unique().to_list())
84+
all_split_donors = train_donors | test_donors
85+
assert all_original_donors == all_split_donors, "Some donors were lost during split"
86+
87+
# Verify test size is approximately correct at donor level
88+
donor_test_ratio = len(test_donors) / (len(train_donors) + len(test_donors))
89+
assert 0.1 <= donor_test_ratio <= 0.3, f"Test donor ratio {donor_test_ratio:.2f} is not close to target 0.2"
90+
91+
# Verify all cells from same donor are in same split
92+
for donor in all_original_donors:
93+
donor_train_count = train_df.filter(pl.col('donor_id') == donor).height
94+
donor_test_count = test_df.filter(pl.col('donor_id') == donor).height
95+
assert (donor_train_count > 0 and donor_test_count == 0) or \
96+
(donor_train_count == 0 and donor_test_count > 0), \
97+
f"Donor {donor} has cells in both train ({donor_train_count}) and test ({donor_test_count})"
98+
99+
print(f"✓ Donor-level split validation passed:")
100+
print(f" - Train donors: {len(train_donors)}")
101+
print(f" - Test donors: {len(test_donors)}")
102+
print(f" - Train cells: {train_df.height}")
103+
print(f" - Test cells: {test_df.height}")
104+
print(f" - No donor leakage detected!")
105+
106+
107+
def test_fallback_to_cell_level_split_without_donor_column():
108+
"""Test that the split falls back to cell-level when no donor column exists."""
109+
110+
# Create synthetic test data WITHOUT donor column
111+
np.random.seed(42)
112+
n_cells = 500
113+
114+
data = []
115+
for i in range(n_cells):
116+
data.append({
117+
'age': np.random.uniform(20, 80),
118+
'gene_sentence_2000': 'gene1 gene2 gene3',
119+
'cell_type': f'type_{np.random.randint(1, 5)}'
120+
})
121+
122+
df = pl.DataFrame(data)
123+
124+
# Write to temporary parquet files
125+
with tempfile.TemporaryDirectory() as tmpdir:
126+
chunks_dir = Path(tmpdir) / 'chunks'
127+
chunks_dir.mkdir(parents=True, exist_ok=True)
128+
129+
# Write in chunks
130+
chunk_size = 100
131+
n_chunks = (len(df) + chunk_size - 1) // chunk_size
132+
for i in range(n_chunks):
133+
start_idx = i * chunk_size
134+
end_idx = min(start_idx + chunk_size, len(df))
135+
chunk = df.slice(start_idx, end_idx - start_idx)
136+
chunk.write_parquet(chunks_dir / f"chunk_{i:04d}.parquet")
137+
138+
# Run the split (should fall back to cell-level)
139+
from cell2sentence4longevity.preprocessing.train_test_split import create_train_test_split
140+
141+
output_dir = Path(tmpdir) / 'output'
142+
create_train_test_split(
143+
parquet_dir=chunks_dir,
144+
output_dir=output_dir,
145+
dataset_name='test_dataset',
146+
test_size=0.2,
147+
random_state=42,
148+
chunk_size=100,
149+
compression='zstd',
150+
compression_level=3,
151+
use_pyarrow=True
152+
)
153+
154+
# Load train and test sets
155+
train_dir = output_dir / 'test_dataset' / 'train'
156+
test_dir = output_dir / 'test_dataset' / 'test'
157+
158+
train_df = pl.scan_parquet(train_dir / '*.parquet').collect()
159+
test_df = pl.scan_parquet(test_dir / '*.parquet').collect()
160+
161+
# Verify split happened (basic sanity check)
162+
total_cells = train_df.height + test_df.height
163+
test_ratio = test_df.height / total_cells
164+
165+
assert 0.15 <= test_ratio <= 0.25, f"Test ratio {test_ratio:.2f} is not close to target 0.2"
166+
167+
print(f"✓ Cell-level split validation passed (fallback when no donor column):")
168+
print(f" - Train cells: {train_df.height}")
169+
print(f" - Test cells: {test_df.height}")
170+
print(f" - Test ratio: {test_ratio:.2%}")
171+
172+
173+
if __name__ == '__main__':
174+
pytest.main([__file__, '-v'])
175+

0 commit comments

Comments
 (0)