Skip to content

Graph descriptors for machine learning

The per-atom and per-bond quantities a graph neural network reads, in one call each, computed the same way the reference implementation computes them.

Mol.atom_descriptors() twelve values per atom
Mol.bond_descriptors() seven values per bond
>>> import omgkit
>>> m = omgkit.parse_smiles("CC(=O)Oc1ccccc1C(=O)O"); m.sanitize()
>>> m.atom_descriptors()[1]
{'atomic_num': 6, 'total_degree': 3, 'formal_charge': 0,
 'chiral_tag': 'unspecified', 'total_num_hs': 0, 'hybridization': 'sp2',
 'is_aromatic': False, 'is_in_ring': False, 'mass': 12.011,
 'electronegativity': 2.55, 'gasteiger_charge': 0.3075...,
 'gasteiger_valid': True}
>>> m.bond_descriptors()[1]
{'begin': 1, 'end': 2, 'order': 'double', 'is_conjugated': True,
 'is_in_ring': False, 'stereo': 'none', 'stereo_atoms': None}

What you get

Per atom

Key Type
atomic_num int element; 0 is the wildcard *
total_degree int explicit neighbours plus total hydrogens
formal_charge int
chiral_tag str unspecified, cw, ccw, allene, square_planar, trigonal_bipyramidal, octahedral
total_num_hs int declared + implied; not free-standing [H] atoms in the graph
hybridization str unspecified, s, sp, sp2, sp3, sp2d, sp3d, sp3d2
is_aromatic bool
is_in_ring bool
mass float the exact mass of the labelled nuclide if an isotope was given, otherwise the standard atomic weight
electronegativity float \| None Pauling
gasteiger_charge float Gasteiger–Marsili partial charge (PEOE)
gasteiger_valid bool whether the line above is a real number

Per bond

Key Type
begin, end int atom indices
order str unspecified, single, double, triple, quadruple, aromatic, dative
is_conjugated bool
is_in_ring bool
stereo str none, cis, trans (see below)
stereo_atoms tuple[int, int] \| None the two atoms stereo is measured against

Descriptors, not an encoding

Categorical values come back as names"sp3", "ccw" — never one-hot vectors and never integer codes. Which elements go in your vocabulary, whether you keep an "other" bucket, how you scale the three continuous values: those are decisions belonging to your featurizer, and freezing one model's answer into a library would make it wrong for the next model. Integer codes would be worse still: they are invisible when they shift.

Building a 45-dimensional atom vector on top is a dozen lines:

ELEMENTS = ["C", "N", "O", "F", "P", "S", "Cl", "Br", "I"]
ATOMIC_NUMS = [6, 7, 8, 9, 15, 16, 17, 35, 53]

def one_hot(value, vocabulary):
    """Last slot is the 'other' bucket."""
    v = [0] * (len(vocabulary) + 1)
    v[vocabulary.index(value) if value in vocabulary else -1] = 1
    return v

def atom_vector(d):
    return (
        one_hot(d["atomic_num"], ATOMIC_NUMS)
        + one_hot(d["total_degree"], list(range(6)))
        + one_hot(d["formal_charge"], [-2, -1, 0, 1, 2])
        + one_hot(d["hybridization"], ["sp", "sp2", "sp3", "sp3d", "sp3d2"])
        + [d["is_aromatic"] * 1, d["is_in_ring"] * 1]
        + [d["mass"] * 0.01]
        + [(d["electronegativity"] or 2.0) / 4]
        + [d["gasteiger_charge"] if d["gasteiger_valid"] else 0.0]
        + [d["gasteiger_valid"] * 1]
    )

Two ways a value can be missing

electronegativity is None when the element has no accepted Pauling value — the noble gases, and Pm, Eu, Tb, Yb, Fr. That is different from "not measured yet" and different from zero.

gasteiger_valid is False when the atom falls outside the Gasteiger parameter set, which covers H, C, N, O, F, Si, P, S, Cl, Br, I, B, Be, Mg and Al. Most metals do not, and the failure spreads along the graph: a carbon bonded to a sodium ends up with a nan charge too. Across the five corpora the gate runs on (9 088 molecules), 4 226 atoms came out invalid — and RDKit produces nan for exactly the same ones.

Both are reported honestly rather than filled in with a default. A default would merge "we do not know" with "the value happens to be that", and your featurizer is exactly the code that needs to tell those apart — which is what the gasteiger_valid flag is for.

cis/trans, not Z/E

Z and E are defined by CIP priority, and omgkit does not implement CIP ranking. What you get instead is the geometry relative to the two atoms named in stereo_atoms, matching RDKit's SetBondStereoFromDirections (STEREOCIS/STEREOTRANS) rather than AssignStereochemistry's STEREOZ/STEREOE.

The two carry the same geometric information: given the reference atoms you can convert one to the other by ranking them yourself. That is why stereo_atoms is part of the descriptor and not an extra — read on its own, stereo means nothing, because on a tetrasubstituted double bond a different choice of reference flips the label.

Sanitize first

Aromaticity, ring membership, hybridization, conjugation and implicit hydrogen counts are all filled in by sanitization; double-bond geometry is filled in by the step right after it. Mol.sanitize() does both.

Skipping it does not raise — you get a full set of descriptors made of parse-time placeholders.

The Rust API

use omgkit_chem::{atom_descriptors, bond_descriptors, sanitize};

let mut m = omgkit_io::smiles::parse("C/C=C/C(=O)O")?;
sanitize(&mut m)?;
omgkit_io::stereo::perceive_bond_stereo(&mut m);   // see below

let a = atom_descriptors(&m);   // Vec<AtomDescriptors>
let b = bond_descriptors(&m);   // Vec<BondDescriptors>
assert!(a[0].gasteiger_is_valid());

The enums come back as enums here (Hybridization::Sp3, BondStereo::Trans), not as strings — the strings exist only at the Python boundary, where there is no enum to hand over.

Rust callers must make that second call themselves

Mol.sanitize() in Python runs two things: omgkit_chem::sanitize, and then omgkit_io::stereo::perceive_bond_stereo, which turns the / and \ of the input into the double bond's own cis/trans property. The second lives in omgkit-io, a sibling of omgkit-chem rather than a dependency, so sanitize cannot call it.

Skipping it does not raise. Every bond just reports BondStereo::None, and nothing about the result looks wrong.

How this is checked

Sixteen of the nineteen values are compared against RDKit atom-by-atom and bond-by-bond over five corpora (9 088 molecules) by harness/check_descriptors.py, which runs in CI. Seven deliberate divergences are pinned by name, each one a documented blind spot on the reference side — for instance RDKit cannot represent allene axial chirality at all.

Which three are not in that count, and where each is guarded instead:

Not compared there Why Guarded by
begin, end bond endpoints, not descriptors the graph itself
stereo_atoms the reference atoms a stereo value is measured against harness/check_bond_stereo.py

And two things that gate can see only partly:

  • the value of the Pauling electronegativity — RDKit exposes no API for it, so a unit test in omgkit-core pins the table instead;
  • the value of stereo (cis vs trans) — compared by harness/check_bond_stereo.py, not here; this gate only checks which bonds carry a configuration.

All of that is written down in the judge's own header, along with the mutation runs that establish it.