Skip to content

Commit e846fc2

Browse files
author
Kashish Mendiratta
committed
Make DataSAIL scaling portfolio ready
1 parent 3ce348e commit e846fc2

10 files changed

Lines changed: 219 additions & 128 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
test:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
- uses: actions/setup-python@v5
17+
with:
18+
python-version: "3.11"
19+
cache: pip
20+
- name: Install test dependencies
21+
run: python -m pip install --upgrade pip && python -m pip install -e ".[test]"
22+
- name: Run tests
23+
run: pytest -q
24+
- name: Check code quality
25+
run: ruff check src tests

‎README.md‎

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Scaling DataSAIL to Large Datasets
22

3+
[![CI](https://github.com/KashishMendiratta/DataSAIL_scaling/actions/workflows/ci.yml/badge.svg)](https://github.com/KashishMendiratta/DataSAIL_scaling/actions/workflows/ci.yml)
4+
[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/)
5+
36
Master's thesis project investigating how to scale [DataSAIL](https://doi.org/10.1038/s41467-025-58606-8) — a leakage-aware dataset splitting framework for molecular ML — to large datasets (10k–100k+ samples), where full pairwise similarity computation and ILP optimization become computationally impractical.
47

58
The project proposes a hybrid approximation: run DataSAIL on a representative subset of the data, then extend the resulting splits to the remaining samples using a **balance-aware k-nearest-neighbour assignment** strategy.
@@ -17,11 +20,13 @@ Across BACE, BBBP, Tox21, and HIV (MoleculeNet benchmarks), the scaled approxima
1720
| BACE (~1.5k) | 3.1s | 5.8s | overhead dominates on small data |
1821
| BBBP (~2k) | 3.8s | 6.3s | overhead dominates on small data |
1922
| Tox21 (~8k) | 31.5s | 21.9s | 1.44x speedup |
20-
| HIV (~41k) | ~1800s (est.) | ~312s | full DataSAIL impractical at this scale |
23+
| HIV (~41k) | ~1800s (est.) | 298.0s | full DataSAIL impractical at this scale |
24+
25+
Leakage fraction (test molecules with a near-duplicate in train, similarity > 0.7) dropped substantially vs. random splitting across all datasets — e.g. for BACE: 84.9% (random) → 7.3% (full DataSAIL) → 1.2–4.1% (scaled). See [`reports/MASTER REPORT.pdf`](reports/MASTER%20REPORT.pdf) for the full leakage, split-quality, and downstream ML performance analysis (Accuracy, F1, MCC, ROC-AUC, and PR-AUC using Random Forest and Logistic Regression classifiers).
2126

22-
Leakage fraction (test molecules with a near-duplicate in train, similarity > 0.7) dropped substantially vs. random splitting across all datasets — e.g. for BACE: 84.9% (random) → 7.3% (full DataSAIL) → 1.2–4.1% (scaled). See `report/` for full leakage, split-quality, and downstream ML performance analysis (Accuracy, F1, MCC, ROC-AUC, PR-AUC on Random Forest / Logistic Regression classifiers).
27+
**Balanced kNN assignment was a necessary addition, not an optional refinement**: naive nearest-neighbour propagation caused runaway train-split dominance (up to ±14.5 percentage-point deviation from target ratios on BBBP), since unassigned molecules statistically favor neighbours in the largest existing partition. The balance-aware correction reduced the maximum deviation to approximately 0.5 percentage points or less in the final four-dataset evaluation.
2328

24-
**Balanced kNN assignment was a necessary addition, not an optional refinement**: naive nearest-neighbour propagation caused runaway train-split dominance (up to ±14.5 percentage-point deviation from target ratios on BBBP), since unassigned molecules statistically favor neighbours in the largest existing partition. The balance-aware correction reduced max deviation to ≤0.5pp across all four datasets.
29+
![Runtime comparison between full and scaled DataSAIL](results/analysis/plots/runtime_comparison.png)
2530

2631
---
2732

@@ -88,8 +93,10 @@ Data-SAIL_scaling/
8893
│ ├── experiments/<dataset>/ # per-dataset splits, runtime logs, split statistics
8994
│ └── analysis/ # aggregated CSVs (leakage, DRE, KL divergence, ML results) + plots/
9095
│
91-
├── report/ # thesis report (LaTeX/PDF) and seminar slides
92-
├── environment.yml
96+
├── reports/ # complete project report
97+
├── tests/ # deterministic unit tests for core algorithms
98+
├── environment.yml # complete research environment
99+
├── pyproject.toml # lightweight package and CI dependencies
93100
└── README.md
94101
```
95102

@@ -99,11 +106,22 @@ Data-SAIL_scaling/
99106

100107
## Setup
101108

109+
For the complete research environment:
110+
102111
```bash
103112
conda env create -f environment.yml
104113
conda activate datasail-scale
105114
```
106115

116+
For the lightweight core package and its tests:
117+
118+
```bash
119+
python -m venv .venv
120+
source .venv/bin/activate
121+
python -m pip install -e ".[test]"
122+
pytest -q
123+
```
124+
107125
---
108126

109127
## Running the Pipeline
@@ -168,7 +186,7 @@ All outputs are written to `results/analysis/` (CSVs) and `results/analysis/plot
168186
2. **Run DataSAIL** on the subset only — similarity computation and ILP optimization scale quadratically with dataset size, so restricting this step to the subset is what makes the approach tractable.
169187
3. **Assign remaining molecules** to train/val/test using balanced kNN: for each unassigned molecule, retrieve k nearest neighbours (Jaccard similarity on ECFP/Morgan fingerprints, radius=2, 2048 bits via RDKit), score each candidate split by similarity × balance-correction factor, and assign to the highest-scoring split. The balance factor penalizes already-oversized splits and boosts underrepresented ones, which is what prevents the train-dominance failure seen under naive nearest-neighbour propagation.
170188

171-
Full derivation, complexity analysis (O(k² + (n−k)·K) vs. O(n²) for full DataSAIL), and evaluation framework (leakage metrics, split-quality metrics, downstream ML performance) are in `report/`.
189+
Full derivation, complexity analysis (O(k² + (n−k)·K) vs. O(n²) for full DataSAIL), and evaluation framework (leakage metrics, split-quality metrics, downstream ML performance) are in [`reports/MASTER REPORT.pdf`](reports/MASTER%20REPORT.pdf).
172190

173191
---
174192

@@ -187,4 +205,4 @@ From [MoleculeNet](https://doi.org/10.1039/c7sc02664a):
187205

188206
## Status
189207

190-
Preliminary/exploratory results (single run, seed=42) — see `report/` conclusion and future work for planned extensions (larger datasets, dynamic hyperparameter tuning for k, evaluation on D-MPNN models).
208+
Research prototype with preliminary single-run results (`seed=42`). The central scaling result is promising, but it should not be interpreted as a statistically replicated benchmark. See the report's conclusion and future-work section for planned extensions, including repeated runs, larger datasets, dynamic tuning of `k`, and evaluation with D-MPNN models.

‎pyproject.toml‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
[build-system]
2+
requires = ["setuptools>=69"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "datasail-scaling"
7+
version = "0.1.0"
8+
description = "A balance-aware approximation for scaling leakage-conscious molecular dataset splitting."
9+
requires-python = ">=3.10"
10+
dependencies = [
11+
"numpy>=1.26,<3",
12+
"pandas>=2.1,<3",
13+
"scikit-learn>=1.4,<2",
14+
]
15+
16+
[project.optional-dependencies]
17+
test = ["pytest>=8,<9", "ruff>=0.12,<1"]
18+
experiments = [
19+
"datasail",
20+
"lightgbm",
21+
"matplotlib",
22+
"memory-profiler",
23+
"pyyaml",
24+
"rdkit",
25+
"seaborn",
26+
"tqdm",
27+
"xgboost",
28+
]
29+
30+
[tool.pytest.ini_options]
31+
testpaths = ["tests"]
32+
33+
[tool.ruff]
34+
line-length = 100
35+
target-version = "py310"
36+
37+
[tool.ruff.lint]
38+
select = ["E4", "E7", "E9", "F"]

‎src/assignment/balanced_knn.py‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,22 @@
44
This addresses the majority class bias in naive kNN assignment by
55
considering both similarity to neighbors AND current split sizes.
66
"""
7+
from collections import Counter
8+
from typing import Mapping
9+
710
import numpy as np
811
import pandas as pd
912
from sklearn.neighbors import NearestNeighbors
10-
from collections import Counter
11-
from typing import Tuple
1213

1314

1415
def balanced_knn_assign(
1516
train_fps: np.ndarray,
1617
train_splits: pd.Series,
1718
remaining_fps: np.ndarray,
1819
k: int = 5,
19-
target_ratios: dict = None,
20-
balance_weight: float = 1.0
21-
) -> Tuple[np.ndarray, np.ndarray]:
20+
target_ratios: Mapping[str, float] | None = None,
21+
balance_weight: float = 1.0,
22+
) -> tuple[np.ndarray, np.ndarray]:
2223
"""
2324
Assign remaining samples using balanced kNN.
2425
@@ -37,7 +38,21 @@ def balanced_knn_assign(
3738
Tuple of (assignments, confidence_scores)
3839
"""
3940
if target_ratios is None:
40-
target_ratios = {'train': 0.7, 'val': 0.2, 'test': 0.1}
41+
target_ratios = {"train": 0.7, "val": 0.2, "test": 0.1}
42+
43+
if len(train_fps) != len(train_splits):
44+
raise ValueError("train_fps and train_splits must have equal lengths")
45+
if not 1 <= k <= len(train_fps):
46+
raise ValueError("k must be between 1 and the number of reference samples")
47+
if balance_weight < 0:
48+
raise ValueError("balance_weight must be non-negative")
49+
if not target_ratios or any(ratio <= 0 for ratio in target_ratios.values()):
50+
raise ValueError("target_ratios must contain positive values")
51+
if not np.isclose(sum(target_ratios.values()), 1.0):
52+
raise ValueError("target_ratios must sum to 1")
53+
unknown_splits = set(train_splits) - set(target_ratios)
54+
if unknown_splits:
55+
raise ValueError(f"Reference data contains unknown splits: {sorted(unknown_splits)}")
4156

4257
# Fit kNN
4358
knn = NearestNeighbors(n_neighbors=k, metric='jaccard', n_jobs=-1)

‎src/assignment/knn_assignment.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import numpy as np
99
import pandas as pd
1010
from sklearn.neighbors import NearestNeighbors
11-
from typing import Dict, Tuple
11+
from typing import Dict
1212
from collections import Counter
1313

1414

@@ -244,4 +244,4 @@ def evaluate_assignment_quality(
244244
)
245245
)
246246

247-
return metrics
247+
return metrics

‎src/downsampling/strategies.py‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"""
77
import numpy as np
88
import pandas as pd
9-
from typing import List, Tuple
9+
from typing import Tuple
1010
from sklearn.cluster import KMeans
1111
from sklearn.metrics.pairwise import euclidean_distances
1212

@@ -24,15 +24,19 @@ def random_downsample(data: pd.DataFrame, ratio: float, seed: int = 42) -> Tuple
2424
Tuple of (sampled_data, remaining_data)
2525
Original indices are preserved!
2626
"""
27-
np.random.seed(seed)
27+
if not 0 < ratio <= 1:
28+
raise ValueError("ratio must be in the interval (0, 1]")
29+
30+
rng = np.random.default_rng(seed)
2831
n_samples = int(len(data) * ratio)
2932

3033
# Get original indices
3134
all_indices = data.index.tolist()
3235

3336
# Randomly select indices
34-
sampled_indices = np.random.choice(all_indices, size=n_samples, replace=False)
35-
remaining_indices = [idx for idx in all_indices if idx not in sampled_indices]
37+
sampled_indices = rng.choice(all_indices, size=n_samples, replace=False)
38+
sampled_index_set = set(sampled_indices)
39+
remaining_indices = [idx for idx in all_indices if idx not in sampled_index_set]
3640

3741
# Use loc to preserve original indices
3842
sampled_data = data.loc[sampled_indices].copy()
@@ -55,7 +59,12 @@ def stratified_downsample(data: pd.DataFrame, labels: pd.Series, ratio: float, s
5559
Tuple of (sampled_data, remaining_data)
5660
Original indices are preserved!
5761
"""
58-
np.random.seed(seed)
62+
if not 0 < ratio <= 1:
63+
raise ValueError("ratio must be in the interval (0, 1]")
64+
if not data.index.equals(labels.index):
65+
raise ValueError("data and labels must have matching indices")
66+
67+
rng = np.random.default_rng(seed)
5968

6069
sampled_indices = []
6170
for label in labels.unique():
@@ -64,10 +73,11 @@ def stratified_downsample(data: pd.DataFrame, labels: pd.Series, ratio: float, s
6473
n_class_samples = int(len(class_indices) * ratio)
6574

6675
# Sample from this class
67-
class_sampled = np.random.choice(class_indices, size=n_class_samples, replace=False)
76+
class_sampled = rng.choice(class_indices, size=n_class_samples, replace=False)
6877
sampled_indices.extend(class_sampled)
6978

70-
remaining_indices = [idx for idx in data.index if idx not in sampled_indices]
79+
sampled_index_set = set(sampled_indices)
80+
remaining_indices = [idx for idx in data.index if idx not in sampled_index_set]
7181

7282
sampled_data = data.loc[sampled_indices].copy()
7383
remaining_data = data.loc[remaining_indices].copy()

‎tests/test_assignment.py‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import numpy as np
2+
import pandas as pd
3+
import pytest
4+
5+
from src.assignment.balanced_knn import balanced_knn_assign
6+
from src.assignment.knn_assignment import evaluate_assignment_quality, knn_assign
7+
8+
9+
def test_knn_assigns_nearest_reference_split() -> None:
10+
reference = np.array([[1, 0, 0], [0, 1, 0]], dtype=bool)
11+
splits = pd.Series(["train", "test"])
12+
queries = np.array([[1, 0, 0], [0, 1, 0]], dtype=bool)
13+
14+
assignments, confidence = knn_assign(reference, splits, queries, k=1)
15+
16+
assert assignments.tolist() == ["train", "test"]
17+
assert confidence.tolist() == [1.0, 1.0]
18+
19+
20+
def test_balanced_assignment_respects_requested_splits() -> None:
21+
reference = np.eye(6, dtype=bool)
22+
splits = pd.Series(["train", "train", "train", "val", "test", "test"])
23+
queries = np.array([[1, 1, 0, 0, 0, 0], [0, 0, 0, 1, 1, 0]], dtype=bool)
24+
25+
assignments, confidence = balanced_knn_assign(
26+
reference,
27+
splits,
28+
queries,
29+
k=2,
30+
target_ratios={"train": 0.5, "val": 0.25, "test": 0.25},
31+
)
32+
33+
assert set(assignments).issubset({"train", "val", "test"})
34+
assert np.all((confidence >= 0) & (confidence <= 1))
35+
36+
37+
def test_balanced_assignment_rejects_invalid_configuration() -> None:
38+
reference = np.eye(2, dtype=bool)
39+
splits = pd.Series(["train", "test"])
40+
41+
with pytest.raises(ValueError, match="sum to 1"):
42+
balanced_knn_assign(
43+
reference,
44+
splits,
45+
reference,
46+
k=1,
47+
target_ratios={"train": 0.8, "test": 0.3},
48+
)
49+
50+
51+
def test_assignment_quality_summary() -> None:
52+
metrics = evaluate_assignment_quality(
53+
np.array(["train", "train", "test"]),
54+
np.array([0.9, 0.4, 1.0]),
55+
)
56+
57+
assert metrics["total_assigned"] == 3
58+
assert metrics["split_distribution"] == {"train": 2, "test": 1}
59+
assert metrics["low_confidence_count"] == 1

‎tests/test_datasail_import.py‎

Lines changed: 0 additions & 48 deletions
This file was deleted.

0 commit comments

Comments
 (0)