Skip to content

Fix PaccMann integration - #447

Open
tereshchuk1 wants to merge 16 commits into
daisybio:developmentfrom
tereshchuk1:pr-388
Open

Fix PaccMann integration#447
tereshchuk1 wants to merge 16 commits into
daisybio:developmentfrom
tereshchuk1:pr-388

Conversation

@tereshchuk1

@tereshchuk1 tereshchuk1 commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

This PR fixes the PaccMann implementation originally submitted in #388 by @gretag04, adapting it to work within the drevalpy framework.

Bug fixes

  • Removed the top-level pytoda imports: pytoda is not a drevalpy dependency. The only code that needed it was the confidence-estimation path, which has since been removed entirely (see below), so neither the import nor the vendored stub remains.
  • Fixed SMILES loading: only the id and SMILES columns are read from drug_smiles.csv; the huge fingerprint bit-string columns are skipped rather than loaded and dropped, since pandas' numeric type inference on them can raise an OverflowError.
  • Fixed gene list name.

Review follow-ups

  • early_stopping is now False. The wrapper does not train against an early-stopping split, so it no longer requests one.
  • Removed dead code: the forward(confidence=...) uncertainty path, _associate_language, utils/interpret.py, the vendored pytoda stub, and the unused gene_projection / smiles_projection helpers in utils/layers.py.
  • Renamed paccmann_v2.py to paccmann_network_v2.py, to make clear that paccmann.py is the DrEval wrapper and that this file holds the network it uses.
  • Removed the module-level logging configuration, which was overriding drevalpy's global logging.
  • predict() now runs batch-wise through a DataLoader instead of pushing all inputs through the network at once.
  • Added self.log_hyperparameters(hyperparameters) in build_model for Weights & Biases logging.
  • gene_list is now a hyperparameter, defaulting to the gene_list_paccmann_network_prop panel that ships with the repo. The TOY test sets it to None, so the toy data does not need that panel.
  • Added documentation: an API page (docs/drevalpy.models.PaccMann.rst, wired into the models toctree) and a row in the model overview table in docs/usage.rst.

@codecov-commenter

codecov-commenter commented Jul 3, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 88.94472% with 44 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.94%. Comparing base (7d24cd6) to head (eb35ae0).
⚠️ Report is 54 commits behind head on development.

Files with missing lines Patch % Lines
drevalpy/models/PaccMann/utils/loss_functions.py 20.83% 19 Missing ⚠️
drevalpy/models/PaccMann/paccmann.py 94.19% 14 Missing ⚠️
drevalpy/models/PaccMann/utils/layers.py 85.36% 6 Missing ⚠️
drevalpy/models/PaccMann/paccmann_network_v2.py 92.53% 5 Missing ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files
@@               Coverage Diff               @@
##           development     #447      +/-   ##
===============================================
+ Coverage        80.34%   84.94%   +4.59%     
===============================================
  Files              101      126      +25     
  Lines             8171    10010    +1839     
===============================================
+ Hits              6565     8503    +1938     
+ Misses            1606     1507      -99     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@PascalIversen

PascalIversen commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Nice, thanks for the fix! I still see some issues:

1.) early_stopping is set to true, but the early stopping set is not used. Could you check if they use early stopping validation in the original implementation and either set it to false if they don't, or implement it?

  1. Can you remove dead code? There is the uncertainty stuff forward(confidence=True) would fail if someone uses it. I think at the moment we don't need it, so we could just remove that. Similar with gene_projection, smiles_projection, alpha_projection

3.) can you add the model to the documentation?

Comment thread drevalpy/models/PaccMann/paccmann.py Outdated

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.

can we rename the file paccmann_network_v2 (to make clear that paccmann.py is not hte first version, but uses this network)

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.

I don't understand this, why are there versions?

Comment thread drevalpy/models/PaccMann/paccmann_v2.py Outdated
Comment thread drevalpy/models/PaccMann/paccmann.py Outdated
Comment thread drevalpy/models/PaccMann/paccmann.py
tereshchuk1 and others added 8 commits July 16, 2026 15:25
…nfidence=...), _associate_language, utils/interpret.py, vendored pytoda) and unused gene_projection/smiles_projection/alpha_projection helpers in utils/layers.py
…for the TOY test, so the model no longer requires the gene_list_paccmann_network_prop panel on toy data
The original implementation (PaccMann/paccmann_predictor) trains for a fixed
number of epochs and checkpoints the model whenever the loss on a held-out set
improves, using that checkpoint as the final model. Follow the same procedure
with the early stopping set: evaluate it after every epoch, keep the weights of
the best epoch, and restore them at the end. Training still runs the full epoch
budget, since the original does not terminate early either.

Also deduplicate the input encoding shared by the validation loader and
predict() into a _encode_inputs helper.
Tokenization: the wrapper split SMILES by character, so multi-character atoms
fell apart -- 'Cl' became C+l and 'Br' became B+r, colliding with carbon and
with boron (Bortezomib really contains boron), and bracket atoms such as
'[C@@h]' or '[Pt+2]' were shredded into their individual characters. Use the
atom-level regex from pytoda.smiles.processing, which is what the original
implementation tokenizes with.

Batch of size 1: torch.squeeze in the context attention layer also dropped the
batch dimension, so predict() crashed whenever the row count left a trailing
batch of one, and training hit the same in the batch norm layers. Squeeze only
the last dimension, and drop a trailing single-sample training batch. The
original implementation always sets drop_last=True; dropping only a size-1
batch keeps training sets smaller than one batch usable.

Hyperparameters: raise smiles_padding_length 128 -> 512 and epochs 3 -> 10, the
values from the original paccmann_v2_params.json. At 128 tokens, 13 CTRPv2
drugs were silently truncated; at 512 no drug in any bundled dataset is.

Also remove six symbols that were defined but referenced nowhere:
dense_attention_layer, to_np, attention_list_to_matrix, Unsqueeze,
RNN_CELL_FACTORY and OPTIMIZER_FACTORY.
The original implementation re-randomizes every SMILES string on each access,
so a drug is seen through a different but chemically identical SMILES in every
epoch. With only a few hundred distinct drugs per dataset this is the main
regularizer on the drug modality, and it was missing here.

Each distinct drug gets a bank of variants built once with RDKit by
re-serializing the molecule from a shuffled atom order; training then draws one
variant per row per batch. The dataset stores the drug index instead of the
encoded SMILES, so no per-epoch tensor is materialized. The vocabulary is built
over all variants, otherwise their tokens would encode as unknown.

Enabled by default via the augment_smiles hyperparameter. RDKit is a new
optional dependency behind the paccmann extra; when it is missing, training
warns and falls back to the unaugmented SMILES rather than failing.
Comments and code carried over from the original repository that never applied
here:

- Eight '# yapf: disable' directives. The original repository formats with yapf,
  drevalpy formats with black, which ignores them; black had already reformatted
  several of the lines they were attached to.
- The prediction_dict returned by forward(). It held attention weights and IC50
  variants 'to ease postprocessing' in the original analysis pipeline, but the
  wrapper always discarded it. It was still built on every evaluation batch, so
  removing it also drops two torch.cat calls per batch from predict(). forward()
  now returns the predictions alone.
- The min_max_scaling branch with IC50_max/IC50_min and get_log_molar. It only
  activates when drug_sensitivity_processing_parameters is passed, which the
  wrapper never does, so it could not run. DrEval scales responses itself.
- The 2128 default for number_of_genes, the gene panel size of the original
  implementation. Both construction paths pass the real count, so requiring it
  turns a stale magic number into a clear error.

The attention weights remain available in the git history and upstream should
interpretability ever be wired up.
@JudithBernett

Copy link
Copy Markdown
Contributor

Nice, thanks for the fix! I still see some issues:

1.) early_stopping is set to true, but the early stopping set is not used. Could you check if they use early stopping validation in the original implementation and either set it to false if they don't, or implement it?

  1. Can you remove dead code? There is the uncertainty stuff forward(confidence=True) would fail if someone uses it. I think at the moment we don't need it, so we could just remove that. Similar with gene_projection, smiles_projection, alpha_projection

3.) can you add the model to the documentation?

@PascalIversen why wouldn't we want to use early stopping no matter the original publication?

Comment on lines +10 to +25
LOSS_FN_FACTORY = {
"mse": nn.MSELoss(),
"l1": nn.L1Loss(),
"mse_and_pearson": mse_cc_loss,
"pearson": correlation_coefficient_loss,
"binary_cross_entropy": nn.BCELoss(),
}

ACTIVATION_FN_FACTORY = {
"relu": nn.ReLU(),
"sigmoid": nn.Sigmoid(),
"selu": nn.SELU(),
"tanh": nn.Tanh(),
"lrelu": nn.LeakyReLU(),
"elu": nn.ELU(),
}

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.

Shouldn't we also tune some of this, then? Or if not, drop?

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.

I don't understand this, why are there versions?

Comment on lines +105 to +112
The original implementation (https://github.com/PaccMann/paccmann_predictor) trains for a fixed number of
epochs and checkpoints the model whenever the loss on a held-out set improves, using that checkpoint as the
final model. This wrapper follows the same procedure with the early stopping set: training always runs for
the full epoch budget and the weights of the best epoch are restored at the end. There is no patience-based
termination, since the original implementation does not stop early either.
"""

early_stopping = True

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.

I don't like that, I think all of our models should follow the same training procedure. If early stopping is set to true, it should mean the same for all the models

Comment on lines +211 to +228
def _normalize_smiles_array(self, smiles_raw: np.ndarray) -> list[str]:
"""Convert SMILES output from FeatureDataset into a list of strings.

:param smiles_raw: raw SMILES array
:return: list of SMILES strings
"""
smiles_raw = np.asarray(smiles_raw, dtype=object)

if smiles_raw.ndim == 2 and smiles_raw.shape[1] == 1:
smiles_raw = smiles_raw[:, 0]

smiles_list = []
for smile in smiles_raw:
if smile is None:
smiles_list.append("")
else:
smiles_list.append(str(smile))
return smiles_list

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.

Suggested change
def _normalize_smiles_array(self, smiles_raw: np.ndarray) -> list[str]:
"""Convert SMILES output from FeatureDataset into a list of strings.
:param smiles_raw: raw SMILES array
:return: list of SMILES strings
"""
smiles_raw = np.asarray(smiles_raw, dtype=object)
if smiles_raw.ndim == 2 and smiles_raw.shape[1] == 1:
smiles_raw = smiles_raw[:, 0]
smiles_list = []
for smile in smiles_raw:
if smile is None:
smiles_list.append("")
else:
smiles_list.append(str(smile))
return smiles_list
def _normalize_smiles_array(self, smiles_raw: np.ndarray) -> list[str]:
"""Convert SMILES output from FeatureDataset into a list of strings.
:param smiles_raw: raw SMILES array
:return: list of SMILES strings
"""
smiles_raw = np.asarray(smiles_raw, dtype=object).reshape(-1)
return ["" if s is None else str(s) for s in smiles_raw]

I'm confused about this function.
smiles_raw should already be an np.ndarray according to the function definition. Why do we need to cast it?

Why do we need this check? Shouldn't the smiles always have the same format because they are always constructed the same way?
This is so long

Comment on lines +7 to +22
def get_device():
"""Return the active torch device.

:return: torch.device("cuda") if cuda is available otherwise torch.device("cpu")
"""
return torch.device("cuda" if cuda() else "cpu")


def cuda():
"""Check whether cuda is available.

:return: True if cuda is available otherwise False.
"""
return torch.cuda.is_available()


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.

really unneccessary functions around basic torch functions, please remove

Comment on lines +23 to +52
class Squeeze(nn.Module):
"""Squeeze wrapper for nn.Sequential."""

def forward(self, data):
"""Squeeze the last dimension of the input tensor.

:param data: input tensor
:return: squeezed tensor
"""
return torch.squeeze(data, -1)


class Temperature(nn.Module):
"""Temperature wrapper for nn.Sequential."""

def __init__(self, temperature):
"""Initialize the temperature wrapper.

:param temperature: Temperature value used for scaling.
"""
super().__init__()
self.temperature = temperature

def forward(self, data):
"""Scale the input tensor by the temperature value.

:param data: input tensor
:return: scaled tensor
"""
return data / self.temperature

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.

also here: why do these need to be modules? these are basic functions

Comment on lines +7 to +35
def pearsonr(x, y):
"""Compute Pearson correlation.

:param x: 1D vector
:param y: 1D vector of the same size as x
:return: Pearson correlation coefficient
:raises TypeError: if inputs are not torch.Tensors
:raises ValueError: if inputs are not 1D, have different lengths, have length < 2 or are constant
"""
if not isinstance(x, torch.Tensor) or not isinstance(y, torch.Tensor):
raise TypeError("Function expects torch Tensors.")

if len(x.shape) > 1 or len(y.shape) > 1:
raise ValueError("x and y must be 1D Tensors.")

if len(x) != len(y):
raise ValueError("x and y must have the same length.")

if len(x) < 2:
raise ValueError("x and y must have length at least 2.")

# If an input is constant, the correlation coefficient is not defined.
if bool((x == x[0]).all()) or bool((y == y[0]).all()):
raise ValueError("Constant input, r is not defined.")

mx = x - torch.mean(x)
my = y - torch.mean(y)
cost = torch.sum(mx * my) / (torch.sqrt(torch.sum(mx**2)) * torch.sqrt(torch.sum(my**2)))
return torch.clamp(cost, min=-1.0, max=1.0)

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.

let's just use the scipy.stats implementation here?

Comment on lines +58 to +59
mse_loss_fn = nn.MSELoss()
mse_loss = mse_loss_fn(predictions, labels)

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.

Suggested change
mse_loss_fn = nn.MSELoss()
mse_loss = mse_loss_fn(predictions, labels)
mse_loss = nn.functional.mse_loss(predictions, labels)

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.

5 participants