Real-valued, fixed-size molecular fingerprints β no training, just NumPy (with optional Rust acceleration).
Hyper Fingerprints encodes molecules into continuous vector representations using Holographic Reduced Representations (HRR) with graph message passing. The result is a deterministic, real-valued fingerprint that works as a drop-in feature vector for similarity search, clustering, or any downstream ML task.
from hyper_fingerprints import Encoder, cosine_similarity
enc = Encoder(dimension=512, seed=42)
# Encode molecules (SMILES strings or RDKit Mol objects)
fps = enc.encode(["CCO", "CO", "c1ccccc1"]) # shape: (3, 512), dtype: float64
# Cosine similarity β similar molecules get similar vectors
sim = cosine_similarity(fps, fps)
print(f"ethanol vs methanol: {sim[0, 1]:.3f}") # high similarity
print(f"ethanol vs benzene: {sim[0, 2]:.3f}") # low similarityTo use a custom atom vocabulary, pass atom_types at init:
enc = Encoder(dimension=512, atom_types=["C", "N", "O", "H", "Si"])See examples/00_quickstart.ipynb for a full
walkthrough covering similarity search, joint fingerprints, custom atom types,
save/load, and scikit-learn integration.
Encoder(
dimension=256, # hypervector size
depth=3, # message-passing layers (structural context radius)
atom_types=None, # atom vocabulary (default: Br, C, Cl, F, I, N, O, P, S)
seed=None, # random seed for reproducible codebook generation
normalize=False, # L2-normalize after each message-passing layer
backend="auto", # "auto" | "rust" | "numpy"
unit_modulus=True, # unit-magnitude (FHRR) codebooks; False = Gaussian codebooks of < 0.5.0
)Molecules can be passed as SMILES strings, RDKit Mol objects, or lists of
either.
encode(molecules) -> np.ndarray β Encode molecules into order-N
hypervector fingerprints. Returns shape (batch_size, dimension).
encode_joint(molecules) -> np.ndarray β Concatenation of order-0 (atom
identity only, no structural context) and order-N (full message-passing)
embeddings. Returns shape (batch_size, 2 * dimension). Useful when you want
both local atom-level and structural information in one vector.
save(path) / Encoder.load(path) β Persist and restore an encoder
(config + codebook) as a single .npz file. Useful for sharing a fixed
fingerprint scheme or deploying without needing to track the seed.
enc.save("encoder.npz")
loaded = Encoder.load("encoder.npz")Every random base vector (the product-codebook rows, the categorical feature
vectors and the base spectra of continuous features) is sampled with Fourier
coefficients of magnitude one: a Gaussian draw keeps only its random phases,
as in Fourier holographic reduced representations (FHRR). Binding by circular
convolution multiplies spectra, and message passing binds at every layer, so
the random magnitudes of Gaussian vectors compound until a few Fourier
components carry most of a fingerprint. With unit-magnitude vectors binding
preserves norms and the fingerprint keeps using all components (at
dimension=2048, depth=2: ~390-480 effective components instead of
~8-75), which also makes results far less dependent on the seed. This
matches the encoder of the HDF paper and graph_hdc's continuous-mode
encoders (the legacy product codebook has no graph_hdc counterpart and is
switched as well).
Unit-magnitude codebooks are the default since 0.5.0, so the same seed
gives different fingerprints than in 0.4.x. unit_modulus=False reproduces
the Gaussian codebooks of earlier versions bit for bit. Saved encoders keep
the vectors they were saved with.
| Parameter | Guidance |
|---|---|
dimension |
32-256 for Bayesian optimization. 1024-2048 as a starting point for property prediction. |
depth |
Controls structural context radius, analogous to Morgan radius. depth=3 captures up to 3-bond neighborhoods. Higher values capture more global structure but increase computation. |
Each atom is described by 5 discrete features:
| Feature | Bins | Values |
|---|---|---|
| Atom type | len(atom_types) (varies with vocabulary) |
Index into the atom vocabulary |
| Degree | 6 | 0-5 |
| Formal charge | 3 | neutral, positive, negative |
| Total Hs | 4 | 0-3 |
| Is aromatic | 2 | 0, 1 |
Passing features as a dict switches to the composite encoder: every
feature gets its own codebook, and the per-feature hypervectors are bound
together with circular convolution. Count-like features can then be encoded
continuously via Fourier Holographic Reduced Representations, so that
degree 3 is close to degree 4 rather than orthogonal to it:
from hyper_fingerprints import Encoder, FEATURES_CONTINUOUS, GRAPH_FEATURES_GRAPH_HDC
enc = Encoder(dimension=1024, seed=42, features=FEATURES_CONTINUOUS, normalize=True)
fps = enc.encode(["CCCC", "CCCCC", "c1ccccc1"])FEATURES_CONTINUOUS expands to
{
"element": {"kind": "categorical"},
"degree": {"kind": "continuous", "bandwidth": 2.0},
"hydrogens": {"kind": "continuous", "bandwidth": 2.0},
"charge": {"kind": "categorical"},
"aromatic": {"kind": "categorical"},
}A continuous value v is encoded as ifft(spectrum ** (v / bandwidth));
bandwidth sets how far apart two values must be before their vectors stop
resembling each other. Any built-in feature (element, degree, charge,
hydrogens, implicit_hydrogens, aromatic) can be used with either kind;
seed_offset (default 10 * position) decorrelates the per-feature codebooks.
Other presets: FEATURES_GRAPH_HDC / GRAPH_FEATURES_GRAPH_HDC mirror the
configuration of the graph_hdc
project; FEATURES_DEFAULT is the legacy list.
Graph-level features (graph_size, graph_diameter, graph_total_charge)
can be added with graph_features= and enter the fingerprint according to
graph_mode:
graph_mode |
Result |
|---|---|
"bundle" (default) |
normalize(normalize(emb) + normalize(graph_hv)) β graph_hdc convention |
"bind" |
emb β graph_hv |
"concat" |
`[emb |
Further options: bidirectional=False uses a single directed edge per bond
(graph_hdc's default), unknown_atoms="slot" maps atoms outside atom_types
to a shared extra codebook row instead of raising.
Recommendation: use normalize=True with continuous features; without
per-layer normalization the magnitudes compound across message-passing
layers.
# in graph_hdc
hyper_net.export_legacy_codebook("codebook.npz")
# in hyper_fingerprints -- identical embeddings, no torch required
enc = Encoder.from_legacy_codebook("codebook.npz")The composite encoder runs on the Rust backend too: both its node encoding (per-feature lookups and FHRR fractional powers) and its message passing are Rust-accelerated, ~3x end-to-end over the NumPy backend. Node encoding additionally computes each distinct feature row only once β atoms of the same kind share a hypervector exactly β which is a ~39x saving on the NumPy backend and applies to both. All codebook sampling stays in NumPy, so seeded reproducibility is unchanged. Composite molecules are still parsed by RDKit β the Rust SMILES parser covers the legacy feature set only β which is now the dominant remaining cost.
- No bond type features β bonds are treated as unweighted edges. Single, double, and aromatic bonds are not distinguished in the current feature scheme.
- No stereochemistry β chirality and cis/trans isomerism are not encoded.
- No GPU acceleration β encoding is CPU-only (NumPy or optional Rust extension).
- No Rust SMILES parsing for composite features β the Rust parser extracts the legacy feature set only, so composite (dict-features) input is parsed by RDKit. Node encoding and message passing are Rust-accelerated in both modes.
- Legacy single-atom batching quirk β in the default (list-features)
mode, a single-atom molecule such as
CorOencodes differently alone than in a batch with bonded molecules. Kept for output stability; the composite (dict-features) encoder is batch-invariant. - Codebook scales with vocabulary β the codebook has
product(feature_bins)entries (1296 for the default 9 atom types). Large custom atom type lists will increase memory usage. - Unnormalized embeddings overflow at large
depthβ each layer multiplies hypervector magnitudes, sonormalize=Falsereaches ~1e2 by depth 3 and overflowsfloat64at depth ~10 (withunit_modulus=False: ~1e4 to 1e7 by depth 3, overflow at ~9). The NumPy backend returnsinf/NaN; the Rust backend raisesValueError. Usenormalize=Truefor depth beyond ~4. - Keep
bandwidthon the order of the feature's value range β a continuous feature encodes asspectrum ** (value / bandwidth), so a bandwidth far below the values makes neighbouring values nearly orthogonal. Withunit_modulus=Falsethe random spectral magnitudes are raised to that power too, which drives the embedding magnitude up (a bandwidth of 0.05 on a feature reaching 3 gives ~1e63, numerically meaningless on either backend; far smaller bandwidths raiseValueErroron the Rust backend and yieldNaNon NumPy). Unit-magnitude spectra keep magnitude one at any bandwidth.
The Rust and NumPy backends are not bit-identical β they use different FFT
implementations, and Rust evaluates z ** e as exp(e Β· ln z) where NumPy
switches to repeated multiplication for real integer exponents with |e| < 100.
Measured deviation, scaled by each row's own magnitude, over 100 real PubChem
molecules across dimensions 64β512, both feature kinds and both normalize
settings:
| regime | agreement |
|---|---|
| depth 1β3 | better than 1e-14 |
| depth 4β6 | better than 5e-13 |
| depth 7β8 | usually ~1e-13, but excursions to ~1e-9 |
| magnitudes near the float64 ceiling (~1e186) | arbitrary β meaningless on either backend |
bandwidth far below the value range, unit_modulus=False (magnitudes ~1e63) |
~5.6e-7 |
Depth 6 is the supported range for tight agreement. Message passing is
ill-conditioned at greater depth: each layer binds the state with an aggregate
of its neighbours, so rounding differences amplify β perturbing the input by one
ULP moves the NumPy result by a comparable amount. This is a property of the
algorithm, not of the port. All of these boundaries are pinned by tests in
tests/test_rust_backend.py so they cannot drift unnoticed.
Requires Python 3.9+ and a Rust toolchain (1.83+).
git clone https://github.com/the16thpythonist/hyper-fingerprints.git
cd hyper-fingerprints
# Install maturin (builds the Rust extension)
pip install maturin
# Build and install in development mode (editable, release-optimized)
RUSTFLAGS="-C target-cpu=native" maturin develop --release
# Verify the Rust backend is available
python -c "from hyper_fingerprints._core import encode_batch_rs; print('Rust OK')"# Build the wheel first
./build.sh
# Install the wheel
pip install target/wheels/hyper_fingerprints-*.whlnumpy >= 1.24rdkit >= 2024.0.0- Rust toolchain >= 1.83 (build-time only)
The Rust extension accelerates both SMILES parsing/feature extraction (~23x) and the message-passing pipeline (~22x), for a combined ~22x end-to-end speedup. When installed, it is used automatically:
enc = Encoder(dimension=512, seed=42, backend="rust") # require Rust
enc = Encoder(dimension=512, seed=42, backend="numpy") # force pure Python
enc = Encoder(dimension=512, seed=42, backend="auto") # default: Rust if availableInstall in dev mode with the Rust extension:
pip install maturin
RUSTFLAGS="-C target-cpu=native" maturin develop --release
pip install -e ".[dev]"Run tests:
pytestBuild a wheel and test it in a clean environment:
nox -s build_testRun tests across Python 3.9-3.13 with nox:
nox -s testsFingerprint outputs are regression-tested against recorded fixtures to ensure numerical stability across releases.
This project builds on the theory of Holographic Reduced Representations and Vector Symbolic Architectures:
- Plate, T. A. (1995). Holographic Reduced Representations. IEEE Transactions on Neural Networks, 6(3), 623-641. doi:10.1109/72.377968
- Kanerva, P. (2009). Hyperdimensional Computing: An Introduction to Computing in Distributed Representation with High-Dimensional Random Vectors. Cognitive Computation, 1(2), 139-159. doi:10.1007/s12559-009-9009-8
This project is licensed under the MIT License.
