File size: 7,915 Bytes
1c9c214
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""QM9 molecular graphs as flat token sequences.

QM9 is the standard small-molecule generation benchmark and the one DiGress and
the discrete graph-diffusion line report on. Molecules have at most 9 heavy
atoms drawn from {C, N, O, F}, with bonds in {single, double, triple, aromatic},
so a molecule is fully described by

    9 node slots   (atom type, or absent)
  + 36 edge slots  (bond type, or absent)      = 45 tokens.

That is small enough to train from scratch cheaply, which is the point: the
claim under test is about the training schedule and the decoding rule, not about
model scale.

Why molecules are the right Phase 2. Validity here is a genuine JOINT
constraint - valency must balance at every atom simultaneously - so mode (ii) is
both dominant and, unlike natural text, *checkable*: RDKit says yes or no. The
metric is therefore unambiguous in a way generative perplexity is not, and the
field's live axis (validity at few denoising steps) is exactly the
quality-vs-compute frontier this project is about.

Node and edge slots take disjoint symbol sets, which is a property of the
representation rather than something a model should spend capacity
rediscovering; `allowed_mask` expresses it and the decoders enforce it.
"""
import csv
import os

import numpy as np

MAX_ATOMS = 9
ATOMS = ["C", "N", "O", "F"]
# Kekulised: aromatic rings are stored as alternating single/double bonds, so
# no aromaticity perception is needed to rebuild a molecule. Storing AROMATIC
# directly loses the per-ATOM aromatic flags, and reconstruction then fails with
# "Can't kekulize mol" on every aromatic species - 7% of QM9, and 100% of the
# round-trip failures measured before this fix.
BONDS = ["SINGLE", "DOUBLE", "TRIPLE"]
N_EDGE = MAX_ATOMS * (MAX_ATOMS - 1) // 2      # 36
SEQ_LEN = MAX_ATOMS + N_EDGE                   # 45

EDGES = [(i, j) for i in range(MAX_ATOMS) for j in range(i + 1, MAX_ATOMS)]


class MolTokenizer:
    """One vocabulary, but node and edge positions draw from disjoint parts.

      0            no atom / no bond  (shared "absent" symbol)
      1..4         C N O F            (node slots only)
      5..8         single double triple aromatic (edge slots only)
      9            [MASK]
    """

    def __init__(self):
        self.itos = (["<none>"] + ATOMS + BONDS + ["[MASK]"])
        self.stoi = {s: i for i, s in enumerate(self.itos)}
        self.mask = self.stoi["[MASK]"]
        self.pad = self.mask
        self.none = 0
        self.atom_ids = [self.stoi[a] for a in ATOMS]
        self.bond_ids = [self.stoi[b] for b in BONDS]

    def __len__(self):
        return len(self.itos)

    def allowed_mask(self):
        """(SEQ_LEN, V) boolean: which symbols each position may take."""
        m = np.zeros((SEQ_LEN, len(self)), dtype=bool)
        m[:MAX_ATOMS, self.none] = True
        for i in self.atom_ids:
            m[:MAX_ATOMS, i] = True
        m[MAX_ATOMS:, self.none] = True
        for i in self.bond_ids:
            m[MAX_ATOMS:, i] = True
        return m


def mol_to_seq(mol, tok):
    """RDKit molecule -> (SEQ_LEN,) token array, or None if it cannot be encoded.

    Rejects molecules this representation cannot express exactly, rather than
    encoding them lossily: anything over MAX_ATOMS, anything carrying a formal
    charge (the atom vocabulary is uncharged), and anything that will not
    kekulise. Encoding must be lossless or generated-sample validity is measured
    against a target the encoding itself cannot hit.
    """
    from rdkit import Chem
    n = mol.GetNumAtoms()
    if n > MAX_ATOMS:
        return None
    if any(a.GetFormalCharge() != 0 for a in mol.GetAtoms()):
        return None
    try:
        mol = Chem.Mol(mol)
        Chem.Kekulize(mol, clearAromaticFlags=True)
    except Exception:
        return None
    seq = np.zeros(SEQ_LEN, dtype=np.int64)
    for a in mol.GetAtoms():
        s = a.GetSymbol()
        if s not in tok.stoi:
            return None
        seq[a.GetIdx()] = tok.stoi[s]
    pos = {e: k for k, e in enumerate(EDGES)}
    for b in mol.GetBonds():
        i, j = sorted((b.GetBeginAtomIdx(), b.GetEndAtomIdx()))
        bt = str(b.GetBondType())
        if bt not in tok.stoi:
            return None
        seq[MAX_ATOMS + pos[(i, j)]] = tok.stoi[bt]
    return seq


def seq_to_mol(seq, tok):
    """Token array -> RDKit molecule (unsanitised), or None if incoherent."""
    from rdkit import Chem
    bond_map = {tok.stoi["SINGLE"]: Chem.BondType.SINGLE,
                tok.stoi["DOUBLE"]: Chem.BondType.DOUBLE,
                tok.stoi["TRIPLE"]: Chem.BondType.TRIPLE}
    m = Chem.RWMol()
    idx = {}
    for i in range(MAX_ATOMS):
        t = int(seq[i])
        if t in tok.atom_ids:
            idx[i] = m.AddAtom(Chem.Atom(tok.itos[t]))
    if not idx:
        return None
    for k, (i, j) in enumerate(EDGES):
        t = int(seq[MAX_ATOMS + k])
        if t in bond_map:
            # a bond to an absent atom is incoherent, not merely invalid
            if i not in idx or j not in idx:
                return None
            m.AddBond(idx[i], idx[j], bond_map[t])
    return m


def validity(seqs, tok):
    """Fraction that sanitise, plus canonical SMILES of those that do.

    Sanitisation is the standard validity criterion for this benchmark: it
    enforces valency at every atom at once, which is precisely the joint
    constraint parallel decoding risks breaking.
    """
    from rdkit import Chem
    from rdkit import RDLogger
    RDLogger.DisableLog("rdApp.*")
    smiles = []
    for s in seqs:
        m = seq_to_mol(s, tok)
        if m is None:
            continue
        try:
            mm = m.GetMol()
            Chem.SanitizeMol(mm)
            smi = Chem.MolToSmiles(mm)
            if smi:
                smiles.append(smi)
        except Exception:
            continue
    return len(smiles) / max(1, len(seqs)), smiles


def load_qm9(root="data/qm9", limit=None):
    """Return (sequences, tokenizer, train_smiles_set).

    The SMILES set is kept so novelty can be measured against the training data
    rather than only uniqueness within a sample.
    """
    from rdkit import Chem
    from rdkit import RDLogger
    RDLogger.DisableLog("rdApp.*")
    path = os.path.join(root, "qm9.csv")
    if not os.path.exists(path):
        raise FileNotFoundError(
            f"{path} not found. Fetch once with:\n  mkdir -p {root} && curl -sL -o {path} "
            "https://deepchemdata.s3.us-west-1.amazonaws.com/datasets/qm9.csv")
    tok = MolTokenizer()
    seqs, smis = [], set()
    n_kept, n_seen = [0], 0
    with open(path) as f:
        for r in csv.DictReader(f):
            n_seen += 1
            smi = r.get("smiles")
            if not smi:
                continue
            m = Chem.MolFromSmiles(smi)
            if m is None:
                continue
            s = mol_to_seq(m, tok)
            if s is None:
                continue
            seqs.append(s)
            smis.add(Chem.MolToSmiles(m))
            n_kept[0] += 1
            if limit and len(seqs) >= limit:
                break
    print(f"qm9: kept {n_kept[0]}/{n_seen} molecules "
          f"({100*n_kept[0]/max(1,n_seen):.1f}%); the remainder are charged, "
          f"over {MAX_ATOMS} heavy atoms, or will not kekulise")
    return np.stack(seqs), tok, smis


if __name__ == "__main__":
    X, tok, smis = load_qm9(os.environ.get("QM9ROOT", "data/qm9"), limit=5000)
    print(f"sequences {X.shape}  vocab {len(tok)}  unique SMILES {len(smis)}")
    am = tok.allowed_mask()
    print(f"allowed mask {am.shape}: node slots allow {am[0].sum()} symbols, "
          f"edge slots allow {am[MAX_ATOMS].sum()}")
    # round-trip: every training molecule must re-sanitise from its own tokens
    v, sm = validity(X[:2000], tok)
    print(f"round-trip validity on real data: {v*100:.2f}%  "
          f"(must be ~100% or the encoding is lossy)")