Skip to content

fix(predict+pretrain): rdkit2D predict arg, issue #22, and atom/bond-mode DDP + float-loss fixes - #24

Open
evasnow1992 wants to merge 8 commits into
NVIDIA-BioNeMo:mainfrom
evasnow1992:evax/predict-arg-and-issue22-fixes
Open

fix(predict+pretrain): rdkit2D predict arg, issue #22, and atom/bond-mode DDP + float-loss fixes#24
evasnow1992 wants to merge 8 commits into
NVIDIA-BioNeMo:mainfrom
evasnow1992:evax/predict-arg-and-issue22-fixes

Conversation

@evasnow1992

Copy link
Copy Markdown
Collaborator

Summary

Two related correctness fixes in the prediction / evaluation path:

  1. main.py predict rejects --rdkit2D_normalization_type — the predict command couldn't use rdkit-2D-normalized features.
  2. Bond dropout is silently disabled after the first validation (and desyncs across DDP ranks)fixes bond_drop_rate is silently disabled after the first validation (and diverges across ranks under DDP) #22.

Both are small, self-contained changes with no behavior change for existing correct runs.

Fix 1 — accept --rdkit2D_normalization_type on the predict parser

predict can build rdkit-2D-normalized features (rdkit_2d_normalized_cuik_molmaker), whose featurization reads args.rdkit2D_normalization_type (kermt/data/molgraph.py) and must match the normalization baked into the checkpoint. The flag was defined only on the finetune parser, so any predict run passing it failed with:

main.py: error: unrecognized arguments: --rdkit2D_normalization_type descriptastorus

Added the argument to add_predict_args, mirroring the finetune definition (choices fast/best/descriptastorus, default fast).

Fix 2 — don't disable bond dropout on the shared args (Fixes #22)

predict() set args.bond_drop_rate = 0 on the shared args instance. Training and evaluation reuse one args object, and graphs are rebuilt every epoch (caching off by default), so after the first validation pass bond dropout stayed disabled for every subsequent training epoch. Under DDP, only rank 0 evaluates, so rank 0 trained with bond_drop_rate=0 while the other ranks kept the configured rate, desyncing augmentation across ranks.

Fix: shallow-copy args before overriding bond_drop_rate, so the override is local to the evaluation call and never leaks back into training or other ranks. Applied the same fix to fingerprint.do_generate, which had the identical pattern (impact there is benign since it's a standalone command, fixed for consistency).

Changes

File Change
kermt/util/parsing.py Add --rdkit2D_normalization_type to the predict parser
task/predict.py Shallow-copy args before disabling bond dropout (+ import copy)
task/fingerprint.py Same shallow-copy fix in do_generate (+ import copy)

Testing

  • tests/integration/test_pretrain_finetune.py::test_pretrain_ddp_finetunenow PASSES end-to-end (pretrain → finetune → predict). This test previously failed at the predict step on the unrecognized --rdkit2D_normalization_type argument.
  • Verified the predict parser accepts --rdkit2D_normalization_type descriptastorus, and that both predict() and fingerprint.do_generate() no longer mutate the caller's args.

Fixes #22

The predict command can build rdkit_2d_normalized features
(rdkit_2d_normalized_cuik_molmaker), whose featurization reads
args.rdkit2D_normalization_type (kermt/data/molgraph.py) and must match the
value baked into the checkpoint. The flag was defined only on the finetune
parser, so `main.py predict --rdkit2D_normalization_type ...` failed with
"unrecognized arguments". Add it to the predict parser, mirroring the
finetune definition (choices fast/best/descriptastorus, default fast).

Signed-off-by: Eva Xue <evax@nvidia.com>
…VIDIA-BioNeMo#22)

predict() set args.bond_drop_rate = 0 on the shared args instance. Training
and evaluation reuse one args object and graphs are rebuilt every epoch
(caching off by default), so after the first validation pass bond dropout
stayed disabled for every subsequent training epoch. Under DDP only rank 0
evaluates, so rank 0 trained with bond_drop_rate=0 while other ranks kept
the configured rate, desyncing augmentation across ranks.

Shallow-copy args before overriding bond_drop_rate so the override is local
to the evaluation call and never leaks back into training or other ranks.
Apply the same fix to fingerprint.do_generate, which had the identical
pattern.

Fixes NVIDIA-BioNeMo#22

Signed-off-by: Eva Xue <evax@nvidia.com>
@evasnow1992
evasnow1992 requested a review from sveccham July 13, 2026 21:08
…modes

The three pretrain trainers (KERMTTrainer, KERMTCMIMTrainer,
KERMTHybridTrainer) wrapped the model in DDP without find_unused_parameters.
In embedding_output_type=atom (or bond) mode the encoder produces only one
aggregation level, so the opposite branch's FFNs and the bond/atom vocab + FG
heads receive no gradient; DDP then aborts on the second iteration ("Expected
to have finished reduction ... parameters that were not used in producing
loss").

Enable find_unused_parameters only when embedding_output_type != 'both' (both
exercises every branch, so keep it off to avoid the per-iteration
graph-traversal overhead; static_graph is not viable because dynamic-depth
sampling varies the graph across steps).

Verified locally: hybrid + atom + DDP (WORLD_SIZE=1) crashed on iteration 2
before the change and trains a full epoch after it.

Signed-off-by: Eva Xue <evax@nvidia.com>
…m/bond)

In embedding_output_type=atom (or bond) mode the vocab loss returns a plain
float 0.0 for the branch that has no embeddings (e.g. bond-vocab and bond dist
loss in atom mode). KERMTTrainer.iter called .item() on the task and dist loss
components unconditionally, raising "AttributeError: 'float' object has no
attribute 'item'" once the affected branch was logged.

Guard every loss-component .item() with `if not isinstance(x, float) else x`,
matching the idiom KERMTHybridTrainer already uses (which is why hybrid mode
was unaffected). Covers the train accumulation, eval branch, and the wandb
logging dict.

Verified: vocab + atom + DDP (WORLD_SIZE=1) trains a full epoch + validation.
Signed-off-by: Eva Xue <evax@nvidia.com>
@evasnow1992 evasnow1992 changed the title fix(predict): accept --rdkit2D_normalization_type and stop mutating shared bond_drop_rate (#22) fix(predict+pretrain): rdkit2D predict arg, issue #22, and atom/bond-mode DDP + float-loss fixes Jul 14, 2026
@evasnow1992

Copy link
Copy Markdown
Collaborator Author

Additional fixes on this branch: atom/bond-mode pretraining

While validating the predict changes I found two pre-existing bugs that break multi-GPU pretraining with --embedding_output_type atom (or bond), and folded both fixes into this branch. Neither affects both-mode (the default).

1. DDP aborts: "parameters that were not used in producing loss" (commit 87bc5a5)

Symptom. Pretraining under DDP with --embedding_output_type atom aborts on the second iteration:

RuntimeError: Expected to have finished reduction in the prior iteration ...
parameters that were not used in producing loss.
Parameter indices which did not receive grad for rank N: 86 87 88 ...

Cause. In atom/bond mode the encoder produces only one aggregation level, so the opposite branch's FFNs and the bond/atom vocab + FG heads receive no gradient -- but all three trainers wrapped the model as DDP(self.model, device_ids=[gpu_id]) with the default find_unused_parameters=False. both mode is unaffected (every branch
participates in the loss).
Fix. find_unused_parameters=(self.args.embedding_output_type != 'both') in KERMTTrainer, KERMTCMIMTrainer, and KERMTHybridTrainer.
Why conditional, and not static_graph. Gating on != 'both' keeps the default path at zero overhead; and static_graph=True (the faster alternative for unused params) is not viable here because MTBlock's dynamic-depth sampling varies the graph shape every step.

2. AttributeError: 'float' object has no attribute 'item' (commit 0898359)

Symptom. Vocab-mode pretraining with --embedding_output_type atom crashes:

AttributeError: 'float' object has no attribute 'item'

Cause. In atom/bond mode the vocab loss returns a plain float 0.0 for the branch with no embeddings (e.g. bond-vocab + bond dist loss in atom mode), but KERMTTrainer.iter called .item() on the task/dist loss components unconditionally (train accumulation, eval branch, and wandb logging dict). KERMTHybridTrainer already guarded these, which is why hybrid mode was unaffected.
Fix. Guard every loss-component .item() with ... if not isinstance(x, float) else x, matching the hybrid trainer's idiom.

Verification (local, single-GPU DDP via WORLD_SIZE=1, kermt:latest container)

@greptile-apps

greptile-apps Bot commented Jul 17, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes two correctness bugs in the prediction/pretrain path: bond dropout being permanently disabled (and desynced across DDP ranks) after the first validation pass, and a KeyError crash in the checkpoint consistency check when a predict arg key was absent from an older checkpoint. Several pre-existing bugs are also cleaned up along the way.

  • Bond dropout / DDP desync (issue bond_drop_rate is silently disabled after the first validation (and diverges across ranks under DDP) #22): predict() and fingerprint.do_generate() now shallow-copy args before zeroing bond_drop_rate, so the override is scoped to the evaluation call and never leaks back into training or across DDP ranks. All three trainer classes also gain find_unused_parameters=(embedding_output_type != 'both') so atom/bond-only DDP runs no longer hang on unused parameter detection.
  • Consistency check and return-value fixes: load_checkpoint_for_prediction gains a guard for keys absent from older checkpoints and a features_generator bypass when features_path is supplied; fingerprint.generate_fingerprints and run_evaluation.run_evaluation fix a pre-existing bug where load_checkpoint's tuple return was assigned directly to model instead of being unpacked.

Confidence Score: 5/5

  • This PR is safe to merge; all changes are targeted bug fixes with clear rationale and no behavior change for correctly-configured runs.
  • Every change either restores correct behavior (bond dropout scoping, DDP unused-parameter flag, tuple unpacking) or adds a defensive guard (missing checkpoint key, features_size mismatch, features_generator bypass). The shallow-copy approach is the minimal correct fix for the shared-args mutation, and the DDP flag is conditioned precisely on the modes that need it.
  • No files require special attention; the changes are self-contained and well-commented.

Important Files Changed

Filename Overview
kermt/util/parsing.py Adds a comment explaining that rdkit2D_normalization_type is intentionally absent from the predict parser so the checkpoint's baked-in value flows through via make_predictions() without being shadowed by an argparse default.
kermt/util/utils.py Fixes two real bugs in load_checkpoint_for_prediction: a KeyError when a finetune_predict_consistency_args key is absent from an older checkpoint, and a swapped Finetune/Predict label in the error message. Also adds a features_generator bypass when features_path is supplied, preventing the consistency check from incorrectly rejecting the normal precomputed-features workflow.
task/predict.py Fixes the DDP bond-dropout desync (issue #22) with a shallow copy of args before setting bond_drop_rate=0; switches from load_checkpoint to load_checkpoint_for_prediction; adds an early features_size mismatch check with a human-readable hint before the loader would emit a cryptic tensor-shape error.
task/fingerprint.py Applies the same shallow-copy fix for bond_drop_rate as predict.py; also fixes a pre-existing bug where load_checkpoint's tuple return was assigned directly to model instead of being properly unpacked.
task/kermttrainer.py Adds find_unused_parameters=(embedding_output_type != 'both') to DDP wrapping in all three trainer classes, fixing a DDP hang/error in atom- or bond-only embedding mode where the opposite branch's parameters receive no gradient. Also upgrades av/bv/fg loss accumulation to use isinstance() guards, fixing crashes when those losses are returned as Python floats rather than tensors.
task/run_evaluation.py Fixes a pre-existing bug where load_checkpoint's tuple return was being assigned to model directly instead of unpacked; the old code would have crashed immediately when the tuple was used as a nn.Module.

Sequence Diagram

sequenceDiagram
    participant Train as Training Loop (rank 0..N)
    participant Eval as evaluate() / predict()
    participant DDP as DDP Model
    participant Args as shared args

    Note over Train,Args: Before fix: args.bond_drop_rate mutated globally
    Train->>DDP: "forward pass (bond_drop_rate=configured)"
    Train->>Eval: call evaluate()
    Eval->>Args: "args.bond_drop_rate = 0  ❌ mutates shared object"
    Eval->>DDP: "predict with bond_drop_rate=0"
    Train->>DDP: "next epoch forward (bond_drop_rate=0 ❌ leaked)"

    Note over Train,Args: After fix: args shallow-copied locally
    Train->>DDP: "forward pass (bond_drop_rate=configured ✅)"
    Train->>Eval: call evaluate()
    Eval->>Eval: "args_copy = copy.copy(args)"
    Eval->>Eval: "args_copy.bond_drop_rate = 0"
    Eval->>DDP: "predict with args_copy (bond_drop_rate=0 ✅)"
    Train->>DDP: next epoch forward (original bond_drop_rate preserved ✅)
Loading

Reviews (3): Last reviewed commit: "fix(fingerprint+eval): unpack the (model..." | Re-trigger Greptile

@sveccham sveccham left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for the fixes and sorry for the delay.

Comment thread task/fingerprint.py Outdated
Comment thread task/kermttrainer.py
Comment thread task/kermttrainer.py
@@ -564,7 +570,13 @@ def __init__(self,
self.n_iter = 0

self.model.to(self.gpu_id)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This this line still needed for DDP finetuning?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I think so. DDP itself doesn't move the model to the target device; it only assumes the model is already on the specified device_id and operates from there.

Comment thread task/predict.py Outdated
Comment thread kermt/util/parsing.py Outdated
parser.add_argument('--features_generator', type=str, nargs='*',
choices=get_available_features_generators(),
help='Method of generating additional features')
parser.add_argument('--rdkit2D_normalization_type', type=str, choices=("fast", "best", "descriptastorus"), default='fast',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think rdkit2D_normalization_type in predict should be the same as the checkpoint. Doesn't this give the user an option to use the finetuned checkpoint incorrectly?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I dug into this a bit further and realized this is actually a bigger issue.

A couple of months ago, you checked in a commit that addressed this by introducing a stricter model loading function for prediction, with consistency checks on both rdkit2D_normalization_type and feature_generator. That change also switched main.py in prediction mode to use the new model loader.

Later, when I checked in the larger batch of changes for Contrastive KERMT, I switched back to the default model loader because of compatibility concerns, which inadvertently removed these consistency checks.

To resolve the rdkit2D_normalization_type issue, I'll revert my change here and switch prediction back to using the stricter model loader.

While investigating this, I also found a related issue with feature_generator. It should remain consistent with the fine-tuned checkpoint, but it also shouldn't be inherited blindly, since doing so can cause the feature generation step to be skipped. I'll add another commit to address this properly as well.

…args

Review feedback on PR NVIDIA-BioNeMo#24: `args = copy.copy(args)` rebinds the parameter, so
a later reader cannot tell from any single line whether `args` is the caller's
instance or the local override. Bind the copy to `args_copy` and read every
subsequent field from it.

The rename has to cover the whole function body, not just the two lines at the
top: had only the assignment been renamed, `MolCollator` would have been handed
the caller's un-overridden args and bond dropout would have stayed enabled
during evaluation -- reintroducing issue NVIDIA-BioNeMo#22 in a quieter form. Updated uses:

  predict()     num_tasks, MolCollator(args=...), fingerprint, dataset_type
  do_generate() MolCollator(args=...)

No behaviour change.

Signed-off-by: Eva Xue <evax@nvidia.com>
…loader

60a77fd added --rdkit2D_normalization_type to add_predict_args to fix
"unrecognized arguments". That fixed the error but suppressed a safeguard:
make_predictions() copies every checkpoint arg the predict parser does not
already define (`if not hasattr(args, key)`), so before 60a77fd the value was
inherited from the checkpoint automatically. Giving argparse a default made the
attribute always present, so a model finetuned with normalization "best" was
featurized with "fast" on a plain `main.py predict` -- silently, because nothing
downstream compares them.

The value is baked into the features the checkpoint was trained on, so the only
correct value is the checkpoint's and there is nothing for a user to choose.
Remove the argument and let inheritance supply it.

Restoring the argument was only half of what was lost. 86d4f3a ("disallow
inconsistent usage between finetune and predict") added the argument, added
load_checkpoint_for_prediction() with a finetune/predict consistency check, and
pointed predict at it. c299c38 ("Import grover_fork base for cMIM line") reverted
all three by overwriting those files with the older grover_fork versions, leaving
load_checkpoint_for_prediction() defined but called from nowhere. Point predict
back at it so the consistency check runs again and finetuned weights load
strictly instead of being skipped on a shape mismatch.

Note the return type differs: load_checkpoint_for_prediction returns the model,
load_checkpoint returns (model, state).

The features_generator half of the consistency check does not yet account for
--features_path; that is fixed in the next commit.

Signed-off-by: Eva Xue <evax@nvidia.com>
…_path

Restoring load_checkpoint_for_prediction re-enabled a consistency check that
compares features_generator between finetune and predict. That check assumes
features are always built on the fly, which the normal inference workflow does
not do: agent/scripts/run_inference.py featurizes ahead of time and passes
--features_path, leaving features_generator at its argparse default of None. The
check would then reject every skill-driven prediction against a checkpoint
finetuned with features.

Skip the features_generator comparison when --features_path is supplied. The two
are mutually exclusive by construction -- MoleculeDatapoint raises "Currently
cannot provide both loaded features and a features generator" -- so a mismatch
there is expected rather than a misuse. Also skip keys the checkpoint predates,
and correct the error message, which had the finetune and predict values the
wrong way round.

Guard the feature width in make_predictions. features_size is not in
get_model_args(), so it is recomputed from the prediction data instead of being
inherited; if it disagrees with the finetuned width the FFN input layer is the
wrong shape. Previously load_checkpoint skipped that layer on a shape mismatch
(strict_shape_check defaults to False) and predicted from its random
initialization, exiting 0 with a debug-level note. The strict loader now raises,
but only with a bare tensor-shape message, so compare features_size explicitly
and name the fix: which --features_path to pass, or which --features_generator
to regenerate with.

Verified against the four real invocation shapes: --features_path with no
generator (the workflow that would have regressed), on-the-fly with a matching
generator, on-the-fly with a mismatched generator, and a checkpoint predating the
argument. Also verified the removed flag is inherited from the checkpoint, and
that the features_size guard fires on forgotten and wrong-sized features while
staying quiet when a no-features checkpoint is used without features.

Signed-off-by: Eva Xue <evax@nvidia.com>
…kpoint

load_checkpoint returns (model, state), but generate_fingerprints and
run_evaluation bound the whole tuple to `model` and passed it on as if it were
the module. `main.py fingerprint` therefore failed at do_generate()'s first
statement with "AttributeError: 'tuple' object has no attribute 'eval'", and
run_evaluation failed the same way inside predict().

Same drift as the predict loader fixed in the previous commit: 86d4f3a wrote
these call sites against a load_checkpoint that returned the model alone, and
c299c38 ("Import grover_fork base for cMIM line") replaced utils.py with the
grover_fork version returning a tuple without updating the callers. train.py was
updated, these two were missed.

Unpack the tuple and discard the state, matching train.py:533 and :558. Swept
every load_checkpoint call site outside kermttrainer.py (which has its own
unrelated 4-tuple loader); these two were the only mismatches.

Signed-off-by: Eva Xue <evax@nvidia.com>
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.

bond_drop_rate is silently disabled after the first validation (and diverges across ranks under DDP)

2 participants