| from __future__ import annotations |
|
|
| import argparse |
| import hashlib |
| import json |
| from dataclasses import asdict, dataclass |
| from pathlib import Path |
|
|
| import numpy as np |
| import pandas as pd |
|
|
| COLUMNS = ("protein_sequence", "compound_smiles", "label") |
| AMINO_ACIDS = set("ACDEFGHIKLMNPQRSTUVWY") |
|
|
|
|
| @dataclass(frozen=True) |
| class DatasetProfile: |
| rows: int |
| unique_proteins: int |
| unique_compounds: int |
| unique_pairs: int |
| protein_length_min: int |
| protein_length_max: int |
| protein_length_mean: float |
| smiles_length_min: int |
| smiles_length_max: int |
| smiles_length_mean: float |
| label_min: float |
| label_max: float |
| label_mean: float |
| label_std: float |
| duplicate_pairs: int |
|
|
|
|
| def load_dataset(path: str | Path, nrows: int | None = None) -> pd.DataFrame: |
| frame = pd.read_csv(path, nrows=nrows) |
| missing_columns = set(COLUMNS) - set(frame.columns) |
| if missing_columns: |
| raise ValueError(f"Missing required columns: {sorted(missing_columns)}") |
| frame = frame.loc[:, COLUMNS].copy() |
| if frame.isna().any().any(): |
| counts = frame.isna().sum() |
| raise ValueError(f"Missing values found: {counts[counts > 0].to_dict()}") |
| frame["protein_sequence"] = frame["protein_sequence"].astype(str).str.strip().str.upper() |
| frame["compound_smiles"] = frame["compound_smiles"].astype(str).str.strip() |
| frame["label"] = pd.to_numeric(frame["label"], errors="raise").astype(np.float32) |
| invalid = frame["protein_sequence"].map(lambda sequence: bool(set(sequence) - AMINO_ACIDS)) |
| if invalid.any(): |
| raise ValueError(f"{int(invalid.sum())} protein sequences contain unsupported residues") |
| return frame |
|
|
|
|
| def profile_dataset(frame: pd.DataFrame) -> DatasetProfile: |
| protein_lengths = frame["protein_sequence"].str.len() |
| smiles_lengths = frame["compound_smiles"].str.len() |
| unique_pairs = frame.drop_duplicates(["protein_sequence", "compound_smiles"]).shape[0] |
| return DatasetProfile( |
| rows=len(frame), |
| unique_proteins=frame["protein_sequence"].nunique(), |
| unique_compounds=frame["compound_smiles"].nunique(), |
| unique_pairs=unique_pairs, |
| protein_length_min=int(protein_lengths.min()), |
| protein_length_max=int(protein_lengths.max()), |
| protein_length_mean=float(protein_lengths.mean()), |
| smiles_length_min=int(smiles_lengths.min()), |
| smiles_length_max=int(smiles_lengths.max()), |
| smiles_length_mean=float(smiles_lengths.mean()), |
| label_min=float(frame["label"].min()), |
| label_max=float(frame["label"].max()), |
| label_mean=float(frame["label"].mean()), |
| label_std=float(frame["label"].std()), |
| duplicate_pairs=len(frame) - unique_pairs, |
| ) |
|
|
|
|
| def _stable_fraction(value: str, seed: int) -> float: |
| digest = hashlib.sha256(f"{seed}:{value}".encode("utf-8")).digest() |
| return int.from_bytes(digest[:8], "big") / 2**64 |
|
|
|
|
| def assign_splits( |
| frame: pd.DataFrame, |
| strategy: str = "cold_protein", |
| train_fraction: float = 0.8, |
| validation_fraction: float = 0.1, |
| seed: int = 42, |
| ) -> pd.Series: |
| if train_fraction <= 0 or validation_fraction <= 0: |
| raise ValueError("Train and validation fractions must be positive") |
| if train_fraction + validation_fraction >= 1: |
| raise ValueError("Train plus validation fraction must be below 1") |
|
|
| if strategy == "cold_protein": |
| keys = frame["protein_sequence"] |
| elif strategy == "cold_compound": |
| keys = frame["compound_smiles"] |
| elif strategy == "pair": |
| keys = frame["protein_sequence"] + "|" + frame["compound_smiles"] |
| elif strategy == "random": |
| keys = pd.Series(np.arange(len(frame)).astype(str), index=frame.index) |
| else: |
| raise ValueError("strategy must be one of: cold_protein, cold_compound, pair, random") |
|
|
| fractions = keys.map(lambda value: _stable_fraction(str(value), seed)) |
| splits = np.where( |
| fractions < train_fraction, |
| "train", |
| np.where(fractions < train_fraction + validation_fraction, "validation", "test"), |
| ) |
| return pd.Series(splits, index=frame.index, name="split") |
|
|
|
|
| def make_sample( |
| source: str | Path, |
| destination: str | Path, |
| rows: int = 512, |
| seed: int = 42, |
| ) -> pd.DataFrame: |
| frame = load_dataset(source) |
| if rows >= len(frame): |
| sample = frame |
| else: |
| bins = pd.qcut(frame["label"], q=min(10, rows), duplicates="drop") |
| sample = ( |
| frame.assign(_bin=bins) |
| .groupby("_bin", observed=True, group_keys=False) |
| .apply( |
| lambda group: group.sample( |
| n=max(1, round(rows * len(group) / len(frame))), |
| random_state=seed, |
| ), |
| include_groups=False, |
| ) |
| .head(rows) |
| .loc[:, COLUMNS] |
| ) |
| if len(sample) < rows: |
| remainder = frame.drop(index=sample.index).sample( |
| rows - len(sample), |
| random_state=seed, |
| ) |
| sample = pd.concat([sample, remainder], ignore_index=True) |
| destination = Path(destination) |
| destination.parent.mkdir(parents=True, exist_ok=True) |
| sample.to_csv(destination, index=False) |
| return sample |
|
|
|
|
| def main() -> None: |
| parser = argparse.ArgumentParser( |
| description="Validate, profile, or sample the affinity dataset" |
| ) |
| parser.add_argument("--data", default="data/train.csv") |
| parser.add_argument("--output", default="") |
| parser.add_argument("--sample-rows", type=int, default=0) |
| parser.add_argument("--seed", type=int, default=42) |
| args = parser.parse_args() |
|
|
| if args.sample_rows: |
| if not args.output: |
| parser.error("--output is required with --sample-rows") |
| frame = make_sample(args.data, args.output, args.sample_rows, args.seed) |
| else: |
| frame = load_dataset(args.data) |
| profile = asdict(profile_dataset(frame)) |
| print(json.dumps(profile, indent=2)) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|