Skip to content

Migratimg MPNN tutorial to Keras3 - #2370

Open
maitry63 wants to merge 8 commits into
keras-team:masterfrom
maitry63:mpnn_br_2289
Open

Migratimg MPNN tutorial to Keras3#2370
maitry63 wants to merge 8 commits into
keras-team:masterfrom
maitry63:mpnn_br_2289

Conversation

@maitry63

@maitry63 maitry63 commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator

This PR migrates the Message Passing Neural Network (MPNN) molecular property prediction tutorial from Keras 2 to Keras 3 and built on top of PR #2289.

Original contribution by @LakshmiKalaKadali.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the Message-Passing Neural Network (MPNN) molecular property prediction example to Keras 3, making it backend-agnostic (supporting JAX, PyTorch, and TensorFlow) by replacing tf.RaggedTensor with fixed-size padded buffers and masks, implementing a custom keras.utils.PyDataset, and using a gated readout with hybrid pooling. The code review highlighted several critical issues in both the script and notebook versions: a compilation crash under TensorFlow due to comparing symbolic shapes with Python's max function, potential runtime crashes from unhandled None values when parsing invalid SMILES strings (both during featurization and test visualization), biased training metrics caused by zero-padding the final batch in the dataset, and an incorrect learning rate schedule calculation that uses batch size instead of steps per epoch.

Comment thread examples/graph/mpnn-molecular-graphs.py
Comment thread examples/graph/ipynb/mpnn-molecular-graphs.ipynb
Comment thread examples/graph/mpnn-molecular-graphs.py
Comment thread examples/graph/mpnn-molecular-graphs.py
Comment thread examples/graph/mpnn-molecular-graphs.py
Comment thread examples/graph/ipynb/mpnn-molecular-graphs.ipynb
Comment thread examples/graph/ipynb/mpnn-molecular-graphs.ipynb
Comment thread examples/graph/ipynb/mpnn-molecular-graphs.ipynb
Comment thread examples/graph/mpnn-molecular-graphs.py Outdated
Comment thread examples/graph/ipynb/mpnn-molecular-graphs.ipynb Outdated
@maitry63

maitry63 commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator Author

@gemini-code-assist review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request migrates the MPNN molecular graphs example to Keras 3, making it backend-agnostic to support JAX, PyTorch, and TensorFlow. It replaces TensorFlow-specific ragged tensors with fixed-size buffers, introduces a pre-featurization step to eliminate CPU bottlenecks, and implements a more stable gated readout. The review feedback highlights several critical issues: a bug in the dataset indexing that shifts padding bonds and corrupts atom features, a masking flaw in the gated readout where segment max pooling fails to handle negative features correctly, the inclusion of failed molecules as noisy training placeholders, and redundant code in the bond featurizer that can be simplified.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

batch_pair_indices[i],
batch_mask[i],
) = self.data[real_idx]
batch_labels[i] = self.labels[real_idx]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In MPNNDataset.__getitem__, shifting all bond indices with batch_pair_indices[i] += i * MAX_ATOMS also shifts the unused/padding bonds (which are initialized to [0, 0]). As a result, these padding bonds are mapped to [i * MAX_ATOMS, i * MAX_ATOMS], which represents valid self-loops on the first atom of each molecule.

During message passing, because EdgeNetwork includes a bias term, these padding bonds will produce non-zero messages (equal to bias * neighbor_feat). These messages are then summed into the first atom's features via ops.segment_sum. This means the first atom of each molecule will receive a large number of spurious messages proportional to the number of padding bonds, corrupting the graph representation and making it dependent on the padding size.

To fix this, consider introducing a bond_mask in the dataset and passing it to the model to mask out the messages of padding bonds before they are summed.

Comment on lines +661 to 665
x_max = ops.segment_max(
gated_x,
ops.cast(indicator, "int32"),
num_segments=num_molecules,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In GatedReadout.call, gated_x is multiplied by mask to zero out the features of padded atoms. However, because tanh (used in self.feat) can output negative values, the valid features in gated_x can be negative (e.g., -0.5). Since padded atoms have their features set to 0.0, ops.segment_max will incorrectly select 0.0 (from the padded atoms) instead of the correct negative maximum from the valid atoms (since 0.0 > -0.5).

To prevent padded elements from interfering with the max pooling, we should mask the padded elements with a large negative value (e.g., -1e9) instead of 0.0 before calling ops.segment_max.

Suggested change
x_max = ops.segment_max(
gated_x,
ops.cast(indicator, "int32"),
num_segments=num_molecules,
)
x_max = ops.segment_max(
gated_x + (1.0 - mask) * -1e9,
ops.cast(indicator, "int32"),
num_segments=num_molecules,
)

Comment on lines +354 to +370
# Pre-featurize once to remove the RDKit bottleneck during training.
print("Pre-featurizing Dataset...")
processed_data = []
for smiles_string in tqdm(df.smiles.values):
graph = smiles_to_graph(smiles_string)
if graph is None:
# Placeholder for failed molecules to maintain index alignment
processed_data.append(
(
np.zeros((MAX_ATOMS, atom_featurizer.dim)),
np.zeros((MAX_BONDS, bond_featurizer.dim)),
np.zeros((MAX_BONDS, 2), dtype="int32"),
np.zeros((MAX_ATOMS,)),
)
)
else:
processed_data.append(graph)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In the pre-featurization loop, if smiles_to_graph returns None (because RDKit failed to parse the SMILES), a placeholder tuple of all-zero arrays is appended to processed_data.

While the atom mask will zero out the features during readout, the model is still trained on these all-zero graphs with their real labels (self.labels[real_idx]). This introduces noise into the training process and can degrade model performance.

Instead of keeping placeholders for failed molecules, we should filter them out of the dataset entirely during the pre-featurization step and update the dataframe df accordingly.

# Pre-featurize once to remove the RDKit bottleneck during training.
print("Pre-featurizing Dataset...")
processed_data = []
valid_indices = []
for i, smiles_string in enumerate(tqdm(df.smiles.values)):
    graph = smiles_to_graph(smiles_string)
    if graph is not None:
        processed_data.append(graph)
        valid_indices.append(i)

df = df.iloc[valid_indices].reset_index(drop=True)

Comment on lines +225 to 229
for name_feature, feature_mapping in self.features_mapping.items():
feature = getattr(self, name_feature)(bond)
if feature in feature_mapping:
output[feature_mapping[feature]] = 1.0
return output

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The loop in BondFeaturizer.encode is identical to the one in Featurizer.encode. Since self.dim in BondFeaturizer is already updated to include the extra dimension for None bonds, we can simplify this by calling super().encode(bond) to avoid code duplication.

        return super().encode(bond)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants