File size: 7,927 Bytes
453d2cd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 | # src/data/loader.py
#
# Loads LP-PDBBind and CASF-2016 into clean DataFrames.
# Output columns: pdb_id, seq, smiles, label
import pandas as pd
from pathlib import Path
def load_lppdb(csv_path: Path,
exclude_ids: set = None) -> pd.DataFrame:
"""
Load LP-PDBBind flat CSV.
Relevant columns:
pdb_id β PDB identifier
seq β protein sequence
smiles β ligand SMILES
value β pAffinity (already normalized from Kd/Ki/IC50)
Args:
csv_path: path to LP_PDBBind.csv
exclude_ids: set of lowercase PDB IDs to remove before training
(pass your CASF IDs here to prevent leakage)
Drops rows with missing seq, smiles, or label.
Strips whitespace from sequences and SMILES.
"""
df = pd.read_csv(csv_path)
df = df[['pdb_id', 'seq', 'smiles', 'value']].copy()
df.columns = ['pdb_id', 'seq', 'smiles', 'label']
before = len(df)
df = df.dropna(subset=['seq', 'smiles', 'label'])
df['seq'] = df['seq'].str.strip().str.upper()
df['smiles'] = df['smiles'].str.strip()
df['pdb_id'] = df['pdb_id'].str.lower().str.strip()
df = df[df['seq'].str.len() > 0]
df = df[df['smiles'].str.len() > 0]
after_clean = len(df)
# Remove CASF complexes to prevent data leakage
if exclude_ids:
before_excl = len(df)
df = df[~df['pdb_id'].isin(exclude_ids)]
n_removed = before_excl - len(df)
print(f" Removed {n_removed} CASF complexes from training (leakage prevention)")
df = df.reset_index(drop=True)
print(f"LP-PDBBind: {before} β {after_clean} (after cleaning) "
f"β {len(df)} (after CASF removal)")
return df
def load_casf(casf_dir: Path) -> pd.DataFrame:
"""
Load CASF-2016 CoreSet.
Reads CoreSet.dat for pdb_ids and labels.
Reads protein sequences from <pdb_id>/<pdb_id>_protein.pdb SEQRES records.
Reads ligand SMILES from <pdb_id>/<pdb_id>_ligand.mol2 via RDKit.
Returns DataFrame with same columns as load_lppdb.
"""
from rdkit import Chem
from rdkit import RDLogger
RDLogger.DisableLog('rdApp.*')
coreset_dat = casf_dir / "power_scoring" / "CoreSet.dat"
coreset_dir = casf_dir / "coreset"
# Parse CoreSet.dat β tab/space separated, first col = pdb_id, last = -logKd
records = []
with open(coreset_dat) as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
parts = line.split()
pdb_id = parts[0].lower()
label = float(parts[-3])
records.append({'pdb_id': pdb_id, 'label': label})
dat_df = pd.DataFrame(records)
print(f"CASF CoreSet.dat: {len(dat_df)} entries")
rows = []
dropped = []
for _, row in dat_df.iterrows():
pid = row['pdb_id']
label = row['label']
folder = coreset_dir / pid
# Protein sequence from SEQRES
seq = _parse_seqres(folder / f"{pid}_protein.pdb")
# Ligand SMILES β try mol2 first, then sdf
smiles = _parse_ligand_smiles(folder, pid)
if seq is None or smiles is None:
dropped.append((pid, "seq missing" if seq is None else "smiles missing"))
continue
rows.append({'pdb_id': pid, 'seq': seq, 'smiles': smiles, 'label': label})
df = pd.DataFrame(rows)
print(f"CASF parsed: {len(df)} complexes | dropped: {len(dropped)}")
for pid, reason in dropped:
print(f" dropped {pid}: {reason}")
return df, dropped
def load_casf2013(casf13_dir: Path) -> pd.DataFrame:
"""
Load CASF-2013 CoreSet. Identical structure to CASF-2016:
power_scoring/CoreSet.dat β labels
coreset/<pid>/ β PDB + mol2/sdf files
Returns same (df, dropped) as load_casf.
"""
from rdkit import Chem
from rdkit import RDLogger
RDLogger.DisableLog('rdApp.*')
coreset_dat = casf13_dir / "power_scoring" / "CoreSet.dat"
coreset_dir = casf13_dir / "coreset"
records = []
with open(coreset_dat) as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
parts = line.split()
pdb_id = parts[0].lower()
label = float(parts[-3])
records.append({'pdb_id': pdb_id, 'label': label})
dat_df = pd.DataFrame(records)
print(f"CASF-2013 CoreSet.dat: {len(dat_df)} entries")
rows, dropped = [], []
for _, row in dat_df.iterrows():
pid = row['pdb_id']
label = row['label']
folder = coreset_dir / pid
seq = _parse_seqres(folder / f"{pid}_protein.pdb")
smiles = _parse_ligand_smiles(folder, pid)
if seq is None or smiles is None:
dropped.append((pid, "seq missing" if seq is None else "smiles missing"))
continue
rows.append({'pdb_id': pid, 'seq': seq, 'smiles': smiles, 'label': label})
df = pd.DataFrame(rows)
print(f"CASF-2013 parsed: {len(df)} complexes | dropped: {len(dropped)}")
for pid, reason in dropped:
print(f" dropped {pid}: {reason}")
return df, dropped
# ββ Private helpers βββββββββββββββββββββββββββββββββββββββββββββββββββ
_AA3TO1 = {
'ALA':'A','ARG':'R','ASN':'N','ASP':'D','CYS':'C',
'GLN':'Q','GLU':'E','GLY':'G','HIS':'H','ILE':'I',
'LEU':'L','LYS':'K','MET':'M','PHE':'F','PRO':'P',
'SER':'S','THR':'T','TRP':'W','TYR':'Y','VAL':'V',
# common non-standard β closest standard
'MSE':'M','SEP':'S','TPO':'T','PTR':'Y','HYP':'P',
}
def _parse_seqres(pdb_path: Path) -> str | None:
if not pdb_path.exists():
return None
# Try SEQRES records first (canonical, includes all residues)
seq_by_chain = {}
with open(pdb_path) as f:
for line in f:
if line.startswith('SEQRES'):
chain = line[11]
residues = line[19:].split()
seq_by_chain.setdefault(chain, []).extend(residues)
if seq_by_chain:
chain = max(seq_by_chain, key=lambda c: len(seq_by_chain[c]))
residues = seq_by_chain[chain]
seq = ''.join(_AA3TO1.get(r, 'X') for r in residues)
seq = seq.replace('X', '')
if seq:
return seq
# Fallback: parse ATOM records (some PDB files lack SEQRES)
# Collects unique residues in order of appearance
atom_by_chain = {}
with open(pdb_path) as f:
for line in f:
if not line.startswith('ATOM'):
continue
chain = line[21]
res_name = line[17:20].strip()
res_seq = line[22:26].strip() # residue sequence number
atom_by_chain.setdefault(chain, {})[res_seq] = res_name
if not atom_by_chain:
return None
chain = max(atom_by_chain, key=lambda c: len(atom_by_chain[c]))
residues = [atom_by_chain[chain][k]
for k in sorted(atom_by_chain[chain],
key=lambda x: int(x) if x.lstrip('-').isdigit() else 0)]
seq = ''.join(_AA3TO1.get(r, 'X') for r in residues)
seq = seq.replace('X', '')
return seq if seq else None
def _parse_ligand_smiles(folder: Path, pid: str) -> str | None:
from rdkit import Chem
# Try mol2
mol2_path = folder / f"{pid}_ligand.mol2"
if mol2_path.exists():
mol = Chem.MolFromMol2File(str(mol2_path), removeHs=True)
if mol:
return Chem.MolToSmiles(mol)
# Try sdf
sdf_path = folder / f"{pid}_ligand.sdf"
if sdf_path.exists():
suppl = Chem.SDMolSupplier(str(sdf_path), removeHs=True)
for mol in suppl:
if mol:
return Chem.MolToSmiles(mol)
return None
|