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