diff --git a/.gitignore b/.gitignore index 62293e8..132073c 100644 --- a/.gitignore +++ b/.gitignore @@ -142,3 +142,6 @@ sandbox/ # PyCharm settings .idea + +# Windsurf +.windsurfrules diff --git a/scikit_mol/conversions.py b/scikit_mol/conversions.py index fbfbbdb..b5eee73 100644 --- a/scikit_mol/conversions.py +++ b/scikit_mol/conversions.py @@ -1,11 +1,14 @@ from collections.abc import Sequence from typing import Optional, Union +from abc import ABC, abstractmethod +import rdkit import numpy as np from numpy.typing import NDArray from rdkit import Chem from rdkit.rdBase import BlockLogs from sklearn.base import BaseEstimator, TransformerMixin +from rdkit.Chem.Scaffolds import MurckoScaffold from scikit_mol._constants import DOCS_BASE_URL from scikit_mol.core import ( @@ -127,3 +130,154 @@ def inverse_transform(self, X_mols_list, y=None): raise ValueError(f"Invalid Mols found: {fails}.") return np.array(X_out).reshape(-1, 1) + + +class BaseScaffoldGenerator(BaseEstimator, ABC): + """ + Abstract base class for scaffold generators. + Inherits from scikit-learn's BaseEstimator to allow for + integration into pipelines and hyperparameter tuning. + """ + + @abstractmethod + def get_scaffold(self, mol: Chem.Mol) -> Chem.Mol: + """ + Generates a scaffold for a given RDKit molecule. + Parameters + ---------- + mol : Chem.Mol + The input molecule. + Returns + ------- + Chem.Mol + The resulting scaffold molecule. Can be None if generation fails. + """ + raise NotImplementedError + + +class MurckoScaffoldGenerator(BaseScaffoldGenerator): + """ + Generates Murcko scaffolds for molecules. + This class encapsulates the logic for RDKit's Murcko scaffold + functionality and serves as a concrete implementation of the + BaseScaffoldGenerator. + Parameters + ---------- + include_chirality : bool, default=False + Whether to include chirality in the scaffold. This is passed to + `MurckoScaffold.MurckoScaffoldSmiles`. + make_generic : bool, default=False + Whether to convert the scaffold to a generic representation + (all atoms become carbons, all bonds become single). + """ + + def __init__(self, include_chirality: bool = False, make_generic: bool = False): + self.include_chirality = include_chirality + self.make_generic = make_generic + + def get_scaffold(self, mol: Chem.Mol) -> Chem.Mol: + """Generates a Murcko scaffold for the input molecule.""" + # We always use MurckoScaffoldSmiles to have explicit control over chirality. + scaffold_smiles = MurckoScaffold.MurckoScaffoldSmiles( + mol=mol, includeChirality=self.include_chirality + ) + + # An empty SMILES string is returned for acyclic molecules. + # Chem.MolFromSmiles("") returns None, which is the desired output for these cases. + scaffold_mol = Chem.MolFromSmiles(scaffold_smiles) if scaffold_smiles else None + + if self.make_generic and scaffold_mol: + scaffold_mol = MurckoScaffold.MakeScaffoldGeneric(scaffold_mol) + + return scaffold_mol + + +class MolToScaffoldTransformer(SmilesToMolTransformer): + """ + Transformer for converting RDKit Mol objects to molecular scaffolds. + This transformer assumes the input is already a sequence of Mol objects. + """ + + def __init__( + self, + scaffold_generator: Optional[BaseScaffoldGenerator] = None, + n_jobs: Optional[int] = None, + safe_inference_mode: bool = False, + ): + """ + Parameters + ---------- + scaffold_generator : object, optional + An object with a `get_scaffold(mol)` method that returns an RDKit Mol object. + If None, a default `MurckoScaffoldGenerator()` is used. + n_jobs : int, optional + The maximum number of concurrently running jobs. + `None` is a marker for 'unset' that will be interpreted as `n_jobs=1` + unless the call is performed under a `parallel_config()` context manager + that sets another value for `n_jobs`. + safe_inference_mode : bool, default=False + If `True`, enables safeguards for handling invalid data during inference. + This should only be set to `True` when deploying models to production. + """ + # Call the parent's __init__ to handle shared parameters like n_jobs + # and safe_inference_mode. + super().__init__( + n_jobs=n_jobs, safe_inference_mode=safe_inference_mode + ) + + if scaffold_generator is None: + self.scaffold_generator = MurckoScaffoldGenerator() + else: + if not isinstance(scaffold_generator, BaseScaffoldGenerator): + raise TypeError( + "scaffold_generator must be an instance of BaseScaffoldGenerator." + ) + self.scaffold_generator = scaffold_generator + + def transform( + self, X_mols_list: Sequence[Union[Chem.Mol, InvalidMol]], y=None + ) -> NDArray[Union[Chem.Mol, InvalidMol]]: + """ + Converts RDKit Mol objects into molecular scaffolds. + Parameters + ---------- + X_mols_list : Sequence[Union[Chem.Mol, InvalidMol]] + A sequence of RDKit Mol objects or InvalidMol objects. + Returns + ------- + NDArray[Union[Chem.Mol, InvalidMol]] + An array of scaffold molecules or InvalidMol objects. + """ + # The input is expected to be a list/array of Mol objects. + # We flatten the input in case it's a 2D array from a previous transformer. + mols = np.array(X_mols_list).flatten() + + # Parallelize the scaffold generation for efficiency + scaffold_arrays = parallelized_with_batches( + self._scaffold_transform, mols, self.n_jobs + ) + scaffolds = np.concatenate(scaffold_arrays) + + return scaffolds.reshape(-1, 1) + + def _scaffold_transform(self, mols: Sequence[Union[Chem.Mol, InvalidMol]]): + scaffolds = [] + for mol in mols: + if isinstance(mol, Chem.Mol): + try: + scaffold = self.scaffold_generator.get_scaffold(mol) + if scaffold is None: + # If scaffold generation results in None (e.g., for acyclic molecules), + # we create an InvalidMol object to signify this. + scaffolds.append( + InvalidMol(str(self), "Scaffold generation resulted in an empty molecule.") + ) + else: + scaffolds.append(scaffold) + except Exception as e: + scaffolds.append( + InvalidMol(str(self), f"Error creating scaffold: {e}") + ) + else: + scaffolds.append(mol) # Pass through InvalidMol objects + return np.array(scaffolds) \ No newline at end of file diff --git a/scikit_mol/notebooks/StratifiedGroupShuffleSplit_and_MurckoTransformer.ipynb b/scikit_mol/notebooks/StratifiedGroupShuffleSplit_and_MurckoTransformer.ipynb new file mode 100644 index 0000000..1e81cb3 --- /dev/null +++ b/scikit_mol/notebooks/StratifiedGroupShuffleSplit_and_MurckoTransformer.ipynb @@ -0,0 +1,1880 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "# import the usual suspects\n", + "import os\n", + "import rdkit\n", + "from rdkit import Chem\n", + "import pandas as pd\n", + "from time import time\n", + "import numpy as np\n", + "import sys\n", + "sys.path.append('..')\n", + "import matplotlib.pyplot as plt" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## import dataset ##" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "full_set = True\n", + "\n", + "if full_set:\n", + " csv_file = \"../../tests/data/SLC6A4_active_excape_export.csv\"\n", + " if not os.path.exists(csv_file):\n", + " import urllib.request\n", + "\n", + " url = \"https://ndownloader.figshare.com/files/25747817\"\n", + " urllib.request.urlretrieve(url, csv_file)\n", + "else:\n", + " csv_file = \"../../tests/data/SLC6A4_active_excapedb_subset.csv\"\n", + "\n", + "data = pd.read_csv(csv_file) " + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
Ambit_InchiKeyOriginal_Entry_IDEntrez_IDActivity_FlagpXC50DBOriginal_Assay_IDTax_IDGene_SymbolOrtholog_GroupSMILES
0AZMKBJHIXZCVNL-BXKDBHETNA-N445906436532A5.68382pubchem3932609606SLC6A44061FC1=CC([C@@H]2O[C@H](CC2)CN)=C(OC)C=C1
1AZMKBJHIXZCVNL-UHFFFAOYNA-N114923056532A5.16210pubchem3932589606SLC6A44061FC1=CC(C2OC(CC2)CN)=C(OC)C=C1
2AZOHUEDNMOIDOC-GETDIYNLNA-N444193406532A6.66354pubchem2760599606SLC6A44061FC1=CC=C(C[C@H]2C[C@@H](N(CC2)CC=C)CCCNC(=O)NC...
3AZSKJKSQZWHDOK-VJSLDGLSNA-NCHEMBL10807456532A6.96000chembl206170829606SLC6A44061C=1C=C(C=CC1)C2=CC(=C(N2CC(C)C)C)C(NCCCN3CCN(C...
4AZTPZTRJVCAAMX-UHFFFAOYNA-NCHEMBL5783466532A8.00000chembl205969349606SLC6A44061C1=CC=C2C(=C1)C=C(C(N(C3CCNCC3)C4CCC4)=O)C=C2
\n", + "
" + ], + "text/plain": [ + " Ambit_InchiKey Original_Entry_ID Entrez_ID Activity_Flag \\\n", + "0 AZMKBJHIXZCVNL-BXKDBHETNA-N 44590643 6532 A \n", + "1 AZMKBJHIXZCVNL-UHFFFAOYNA-N 11492305 6532 A \n", + "2 AZOHUEDNMOIDOC-GETDIYNLNA-N 44419340 6532 A \n", + "3 AZSKJKSQZWHDOK-VJSLDGLSNA-N CHEMBL1080745 6532 A \n", + "4 AZTPZTRJVCAAMX-UHFFFAOYNA-N CHEMBL578346 6532 A \n", + "\n", + " pXC50 DB Original_Assay_ID Tax_ID Gene_Symbol Ortholog_Group \\\n", + "0 5.68382 pubchem 393260 9606 SLC6A4 4061 \n", + "1 5.16210 pubchem 393258 9606 SLC6A4 4061 \n", + "2 6.66354 pubchem 276059 9606 SLC6A4 4061 \n", + "3 6.96000 chembl20 617082 9606 SLC6A4 4061 \n", + "4 8.00000 chembl20 596934 9606 SLC6A4 4061 \n", + "\n", + " SMILES \n", + "0 FC1=CC([C@@H]2O[C@H](CC2)CN)=C(OC)C=C1 \n", + "1 FC1=CC(C2OC(CC2)CN)=C(OC)C=C1 \n", + "2 FC1=CC=C(C[C@H]2C[C@@H](N(CC2)CC=C)CCCNC(=O)NC... \n", + "3 C=1C=C(C=CC1)C2=CC(=C(N2CC(C)C)C)C(NCCCN3CCN(C... \n", + "4 C1=CC=C2C(=C1)C=C(C(N(C3CCNCC3)C4CCC4)=O)C=C2 " + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "data.head()" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "7228\n" + ] + } + ], + "source": [ + "print(len(data))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Murcko Scaffold Transformation ###" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "from rdkit.Chem.Scaffolds import MurckoScaffold # for comparison\n", + "from sklearn.pipeline import Pipeline\n", + "from conversions import SmilesToMolTransformer, MolToScaffoldTransformer, MurckoScaffoldGenerator" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "With MolToScaffoldTransformer, we are implementing a generic transformer that reads RDKit mol objects and applies a user-defined scaffold. Currently, we are only supporting Murcko scaffolds, but designed scikit-mol such that it is rather straightforward to add your desired scaffold generator. \n", + "\n", + " The MurckoScaffoldGenerator has two important options:\n", + "\n", + "\n", + " * `include_chirality`: If set to True, stereocenters in the scaffold will be preserved. This is important if the 3D arrangement of your core structure is critical for its function. If False, all stereochemical information is removed.\n", + " * Example: A chiral scaffold might have a SMILES of C1C[C@H](C)CC1, while its non-chiral version would be C1CC(C)CC1.\n", + "\n", + "\n", + " * `make_generic`: If set to True, it converts all atoms in the scaffold to generic carbon atoms and all bonds to single bonds. This is useful for finding more abstract structural relationships, where you only care about the graph connectivity of the ring systems,\n", + " not the specific elements or bond types.\n", + "\n", + " What About Molecules Without Rings?\n", + "\n", + " A Murcko scaffold is only defined for molecules that contain at least one ring. What happens if you have an acyclic molecule like ethanol (CCO) in your dataset? The pipeline handles this automatically and will identify that no scaffold can be generated. It will produce an InvalidMol object for that entry. This ensures that your workflow doesn't crash and you can easily filter out these cases later if needed." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "pipeline = Pipeline([('mol_transformer', SmilesToMolTransformer(safe_inference_mode = True)), ('scaffold_transformer', \n", + " MolToScaffoldTransformer(safe_inference_mode=True, scaffold_generator=MurckoScaffoldGenerator(include_chirality=False, make_generic=False)))])" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "InvalidMol('MolToScaffoldTransformer(safe_inference_mode=True,\n", + " scaffold_generator=MurckoScaffoldGenerator())', error='Scaffold generation resulted in an empty molecule.')\n" + ] + } + ], + "source": [ + "from scikit_mol.core import InvalidMol\n", + "murcko_example = pipeline.transform(['CCO'])\n", + "assert type(murcko_example[0][0]) == InvalidMol\n", + "assert murcko_example[0][0].error == 'Scaffold generation resulted in an empty molecule.'\n", + "print(murcko_example[0][0])" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "murcko = pipeline.transform(data['SMILES'])" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [], + "source": [ + "# The above transformation can be equivalently generated by\n", + "murcko_rdkit = [None] * len(data['SMILES'])\n", + "for i, smiles in enumerate(data['SMILES']):\n", + " murcko_rdkit[i] = MurckoScaffold.MurckoScaffoldSmiles(smiles = smiles, includeChirality = False)" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [], + "source": [ + "## Now more explicit comparison ##" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [], + "source": [ + "pipeline = Pipeline([('mol_transformer', SmilesToMolTransformer(safe_inference_mode = True)), \n", + " ('scaffold_transformer', MolToScaffoldTransformer(safe_inference_mode=True, \n", + " scaffold_generator=MurckoScaffoldGenerator(include_chirality=False, \n", + " make_generic=False)))])\n", + "murcko = pipeline.transform(data['SMILES'])\n", + "for smiles, murcko_scaffold in zip(data['SMILES'], murcko):\n", + " assert MurckoScaffold.MurckoScaffoldSmiles(smiles = smiles, includeChirality = False) == Chem.MolToSmiles(murcko_scaffold[0])" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [], + "source": [ + "pipeline = Pipeline([('mol_transformer', SmilesToMolTransformer(safe_inference_mode = True)), \n", + " ('scaffold_transformer', MolToScaffoldTransformer(safe_inference_mode=True, \n", + " scaffold_generator=MurckoScaffoldGenerator(include_chirality=True, \n", + " make_generic=False)))])\n", + "murcko = pipeline.transform(data['SMILES'])\n", + "for smiles, murcko_scaffold in zip(data['SMILES'], murcko):\n", + " assert MurckoScaffold.MurckoScaffoldSmiles(smiles = smiles, includeChirality = True) == Chem.MolToSmiles(murcko_scaffold[0])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Both assertions passed, seems like we are handling the chirality correctly." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## StratifiedGroupShuffleSplit ##" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "With the StratifiedGroupShuffleSplit, we are providing a scikit-learn cross-validator that satisfies the following properties:\n", + " \n", + "1- Same group does not appear in both training and test sets,\n", + "2- train/test split is done such that the class labels (y) have the same distribution in both sets and the full dataset\n", + "3- For each new split, a fresh random sampling of the data is performed to form a single train and test set. The splits are independent of each other and can overlap.\n", + "\n", + "```python\n", + "StratifiedGroupShuffleSplit.__init__( \n", + " self,\n", + " n_splits: int = 5,\n", + " *,\n", + " test_size: float = 0.2,\n", + " train_size: float = None,\n", + " random_state: int = None,\n", + " sample_weighted: bool = False,\n", + " suppress_warnings: bool = False,\n", + ")\n", + "```\n", + "\n", + "You can define a test and train size in the same way as you do in scikit-learn: a float value will mean a fraction (test_size = 0.2 -> 20% of the data will be used for testing), while an int value will mean a number of samples (test_size = 10 -> 10 samples will be used for testing). \n", + "\n", + "Our implementation of the `StratifiedGroupShuffleSplit` uses a greedy search algorithm to find the best possible split that satisfies the above properties. One key point of the algorithm is that it will create a list of groups that can be properly fit into the test set, and then start placing these groups into the test set by randomly selecting them. `sample_weighted=True` will make the selection use the number of samples in each group as a weight, meaning that groups with more samples will be prioritized. \n", + "\n", + "Some important caveats are, \n", + "\n", + "1- Due to the nature of the optimization problem, we cannot guarantee that the returned `test_size` will be the same as the requested `test_size`. If the requested and returned test set sizes differ by larget than 5%, you will get a warning (provided `suppress_warnings=False`)\n", + "2- It is possible that the dataset contains groups that are too large to fit into your requested `test_size`. In this case, you will get a warning (provided `suppress_warnings=False`)" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [], + "source": [ + "# helper script to make our lives a bit easier and cleaner\n", + "def print_report(splitter, x, y, groups):\n", + " \n", + " for i, (train_index, test_index) in enumerate(splitter.split(x, y, groups)):\n", + " \n", + " y_train_counts, y_test_counts, groups_train_counts, groups_test_counts = (\n", + " np.unique(y[train_index], return_counts=True),\n", + " np.unique(y[test_index], return_counts=True),\n", + " np.unique(groups[train_index], return_counts=True),\n", + " np.unique(groups[test_index], return_counts=True)\n", + " )\n", + " \n", + " print(f\"Split: {i}\")\n", + " print(f\"Train y and their counts: {y_train_counts}\")\n", + " print(f\"Test y and their counts: {y_test_counts}\")\n", + " print(f\"Train set class label distribution: {y_train_counts[1]/len(y[train_index])}\")\n", + " print(f\"Test set class label distribution: {y_test_counts[1]/len(y[test_index])}\")\n", + " print(f\"Train groups and their counts:: {groups_train_counts}\")\n", + " print(f\"Test groups and their counts: {groups_test_counts}\")\n", + " print(f\"Train size: {len(y[train_index])}\")\n", + " print(f\"Test size: {len(y[test_index])}\")\n", + " print(f\"Overlapping groups in train and test splits: {len(set(groups_balanced[train_index]).intersection(set(groups_balanced[test_index])))}\\n\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [], + "source": [ + "# Let's create a dummy balanced dataset\n", + "x = np.random.rand(1000)\n", + "y_balanced = np.random.randint(0,3,1000)\n", + "groups_balanced = np.random.randint(0,10,1000)" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [], + "source": [ + "# Let's create a dummy imbalanced dataset\n", + "x = np.random.rand(1000)\n", + "y_imbalanced = np.random.randint(0,3,1000)\n", + "groups = np.random.randint(0,2,1000)\n", + "y_imbalanced[0:900] = 0\n", + "np.random.shuffle(y_imbalanced)\n", + "groups[0:850] = 1\n", + "groups_imbalanced = groups.copy()" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Unique y and their counts in the full dataset: (array([0, 1, 2]), array([318, 331, 351]))\n", + "Unique groups and their counts in the full dataset: (array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), array([ 80, 99, 122, 102, 114, 91, 105, 92, 99, 96]))\n", + "Split: 0\n", + "Train y and their counts: (array([0, 1, 2]), array([255, 268, 267]))\n", + "Test y and their counts: (array([0, 1, 2]), array([63, 63, 84]))\n", + "Train set class label distribution: [0.32278481 0.33924051 0.33797468]\n", + "Test set class label distribution: [0.3 0.3 0.4]\n", + "Train groups and their counts:: (array([0, 1, 2, 3, 5, 6, 7, 8]), array([ 80, 99, 122, 102, 91, 105, 92, 99]))\n", + "Test groups and their counts: (array([4, 9]), array([114, 96]))\n", + "Train size: 790\n", + "Test size: 210\n", + "Overlapping groups in train and test splits: 0\n", + "\n", + "Split: 1\n", + "Train y and their counts: (array([0, 1, 2]), array([260, 255, 287]))\n", + "Test y and their counts: (array([0, 1, 2]), array([58, 76, 64]))\n", + "Train set class label distribution: [0.32418953 0.31795511 0.35785536]\n", + "Test set class label distribution: [0.29292929 0.38383838 0.32323232]\n", + "Train groups and their counts:: (array([0, 2, 3, 4, 5, 6, 7, 9]), array([ 80, 122, 102, 114, 91, 105, 92, 96]))\n", + "Test groups and their counts: (array([1, 8]), array([99, 99]))\n", + "Train size: 802\n", + "Test size: 198\n", + "Overlapping groups in train and test splits: 0\n", + "\n" + ] + } + ], + "source": [ + "# Groups are splitted, i.e. the same group does not occur both in the test and train split\n", + "from sklearn.model_selection import GroupShuffleSplit\n", + "sss = GroupShuffleSplit(n_splits=2, test_size=0.2)\n", + "print(f\"Unique y and their counts in the full dataset: {np.unique(y_balanced, return_counts=True)}\")\n", + "print(f\"Unique groups and their counts in the full dataset: {np.unique(groups_balanced, return_counts=True)}\")\n", + "print_report(sss, x, y_balanced, groups_balanced)" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Unique y and their counts in the full dataset: (array([0, 1, 2]), array([932, 39, 29]))\n", + "Unique groups and their counts in the full dataset: (array([0, 1]), array([ 70, 930]))\n", + "Split: 0\n", + "Train y and their counts: (array([0, 1, 2]), array([64, 3, 3]))\n", + "Test y and their counts: (array([0, 1, 2]), array([868, 36, 26]))\n", + "Train set class label distribution: [0.91428571 0.04285714 0.04285714]\n", + "Test set class label distribution: [0.93333333 0.03870968 0.02795699]\n", + "Train groups and their counts:: (array([0]), array([70]))\n", + "Test groups and their counts: (array([1]), array([930]))\n", + "Train size: 70\n", + "Test size: 930\n", + "Overlapping groups in train and test splits: 10\n", + "\n", + "Split: 1\n", + "Train y and their counts: (array([0, 1, 2]), array([868, 36, 26]))\n", + "Test y and their counts: (array([0, 1, 2]), array([64, 3, 3]))\n", + "Train set class label distribution: [0.93333333 0.03870968 0.02795699]\n", + "Test set class label distribution: [0.91428571 0.04285714 0.04285714]\n", + "Train groups and their counts:: (array([1]), array([930]))\n", + "Test groups and their counts: (array([0]), array([70]))\n", + "Train size: 930\n", + "Test size: 70\n", + "Overlapping groups in train and test splits: 10\n", + "\n" + ] + } + ], + "source": [ + "# When we have a dataset with a strong class imbalance (NOTE: Group labels are still balanced)\n", + "# Groups are splitted, i.e. the same group does not occur both in the test and train split\n", + "# But the stratification is not guaranteed, i.e. class distributions (y-labels) in the test and train sets are not the same as in the full dataset\n", + "# In your dataset if one class label is occurring much more often than the other class labels (imbalanced dataset), without stratification \n", + "# you are risking of not having the under-represented cl\\asses in the test set\n", + "from sklearn.model_selection import GroupShuffleSplit\n", + "sss = GroupShuffleSplit(n_splits=2, test_size=0.2, random_state = 42)\n", + "print(f\"Unique y and their counts in the full dataset: {np.unique(y_imbalanced, return_counts=True)}\")\n", + "print(f\"Unique groups and their counts in the full dataset: {np.unique(groups, return_counts=True)}\")\n", + "\n", + "\n", + "print_report(sss, x, y_imbalanced, groups)" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Unique y and their counts in the full dataset: (array([0, 1, 2]), array([932, 39, 29]))\n", + "Unique groups and their counts in the full dataset: (array([0, 1]), array([ 70, 930]))\n", + "Split: 0\n", + "Train y and their counts: (array([0, 1, 2]), array([64, 3, 3]))\n", + "Test y and their counts: (array([0, 1, 2]), array([868, 36, 26]))\n", + "Train set class label distribution: [0.91428571 0.04285714 0.04285714]\n", + "Test set class label distribution: [0.93333333 0.03870968 0.02795699]\n", + "Train groups and their counts:: (array([0]), array([70]))\n", + "Test groups and their counts: (array([1]), array([930]))\n", + "Train size: 70\n", + "Test size: 930\n", + "Overlapping groups in train and test splits: 10\n", + "\n", + "Split: 1\n", + "Train y and their counts: (array([0, 1, 2]), array([868, 36, 26]))\n", + "Test y and their counts: (array([0, 1, 2]), array([64, 3, 3]))\n", + "Train set class label distribution: [0.93333333 0.03870968 0.02795699]\n", + "Test set class label distribution: [0.91428571 0.04285714 0.04285714]\n", + "Train groups and their counts:: (array([1]), array([930]))\n", + "Test groups and their counts: (array([0]), array([70]))\n", + "Train size: 930\n", + "Test size: 70\n", + "Overlapping groups in train and test splits: 10\n", + "\n" + ] + } + ], + "source": [ + "# When we have a dataset with a strong class imbalance and strong group imbalance:\n", + "# Groups are splitted, i.e. the same group does not occur both in the test and train split\n", + "# But the stratification is not guaranteed, i.e. class distributions (y-labels) in the test and train sets are not the same as in the full dataset\n", + "# In your dataset if one class label is occurring much more often than the other class labels (imbalanced dataset), without stratification \n", + "# you are risking of not having the under-represented classes in the test set\n", + "from sklearn.model_selection import GroupShuffleSplit\n", + "sss = GroupShuffleSplit(n_splits=2, test_size=0.2, random_state = 42)\n", + "print(f\"Unique y and their counts in the full dataset: {np.unique(y_imbalanced, return_counts=True)}\")\n", + "print(f\"Unique groups and their counts in the full dataset: {np.unique(groups_imbalanced, return_counts=True)}\")\n", + "\n", + "print_report(sss, x, y_imbalanced , groups_imbalanced)" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "(array([0, 1]), array([ 70, 930]))\n", + "Full dataset class label distribution: [0.932 0.039 0.029]\n", + "\n", + "Split: 0\n", + "Train y and their counts: (array([0, 1, 2]), array([583, 24, 20]))\n", + "Test y and their counts: (array([0, 1, 2]), array([349, 15, 9]))\n", + "Train set class label distribution: [0.92982456 0.03827751 0.03189793]\n", + "Test set class label distribution: [0.93565684 0.04021448 0.02412869]\n", + "Train groups and their counts:: (array([2, 4, 5, 6, 8, 9]), array([122, 114, 91, 105, 99, 96]))\n", + "Test groups and their counts: (array([0, 1, 3, 7]), array([ 80, 99, 102, 92]))\n", + "Train size: 627\n", + "Test size: 373\n", + "Overlapping groups in train and test splits: 0\n", + "\n", + "Split: 1\n", + "Train y and their counts: (array([0, 1, 2]), array([657, 26, 16]))\n", + "Test y and their counts: (array([0, 1, 2]), array([275, 13, 13]))\n", + "Train set class label distribution: [0.93991416 0.03719599 0.02288984]\n", + "Test set class label distribution: [0.91362126 0.04318937 0.04318937]\n", + "Train groups and their counts:: (array([0, 1, 2, 3, 6, 7, 8]), array([ 80, 99, 122, 102, 105, 92, 99]))\n", + "Test groups and their counts: (array([4, 5, 9]), array([114, 91, 96]))\n", + "Train size: 699\n", + "Test size: 301\n", + "Overlapping groups in train and test splits: 0\n", + "\n", + "Split: 2\n", + "Train y and their counts: (array([0, 1, 2]), array([624, 28, 22]))\n", + "Test y and their counts: (array([0, 1, 2]), array([308, 11, 7]))\n", + "Train set class label distribution: [0.92581602 0.04154303 0.03264095]\n", + "Test set class label distribution: [0.94478528 0.03374233 0.02147239]\n", + "Train groups and their counts:: (array([0, 1, 3, 4, 5, 7, 9]), array([ 80, 99, 102, 114, 91, 92, 96]))\n", + "Test groups and their counts: (array([2, 6, 8]), array([122, 105, 99]))\n", + "Train size: 674\n", + "Test size: 326\n", + "Overlapping groups in train and test splits: 0\n", + "\n" + ] + } + ], + "source": [ + "# If you use GroupKFold on an imbalanced dataset, you will be able to separate the groups but your splits won't be stratified\n", + "\n", + "from sklearn.model_selection import GroupKFold\n", + "sss = GroupKFold(n_splits=3, shuffle=True, random_state=None)\n", + "groups_counts = np.unique(groups, return_counts=True)\n", + "print(groups_counts)\n", + "print(f\"Full dataset class label distribution: {np.unique(y_imbalanced, return_counts=True)[1]/len(y_imbalanced)}\\n\")\n", + "\n", + "print_report(sss, x, y_imbalanced, groups_balanced)" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "(array([0, 1]), array([ 70, 930]))\n", + "Split: 0\n", + "Train y and their counts: (array([0, 1, 2]), array([223, 234, 238]))\n", + "Test y and their counts: (array([0, 1, 2]), array([ 95, 97, 113]))\n", + "Train set class label distribution: [0.32086331 0.33669065 0.34244604]\n", + "Test set class label distribution: [0.31147541 0.31803279 0.3704918 ]\n", + "Train groups and their counts:: (array([0, 1, 2, 3, 5, 6, 9]), array([ 80, 99, 122, 102, 91, 105, 96]))\n", + "Test groups and their counts: (array([4, 7, 8]), array([114, 92, 99]))\n", + "Train size: 695\n", + "Test size: 305\n", + "Overlapping groups in train and test splits: 0\n", + "\n", + "Split: 1\n", + "Train y and their counts: (array([0, 1, 2]), array([204, 207, 217]))\n", + "Test y and their counts: (array([0, 1, 2]), array([114, 124, 134]))\n", + "Train set class label distribution: [0.32484076 0.32961783 0.3455414 ]\n", + "Test set class label distribution: [0.30645161 0.33333333 0.36021505]\n", + "Train groups and their counts:: (array([2, 4, 6, 7, 8, 9]), array([122, 114, 105, 92, 99, 96]))\n", + "Test groups and their counts: (array([0, 1, 3, 5]), array([ 80, 99, 102, 91]))\n", + "Train size: 628\n", + "Test size: 372\n", + "Overlapping groups in train and test splits: 0\n", + "\n", + "Split: 2\n", + "Train y and their counts: (array([0, 1, 2]), array([209, 221, 247]))\n", + "Test y and their counts: (array([0, 1, 2]), array([109, 110, 104]))\n", + "Train set class label distribution: [0.30871492 0.32644018 0.3648449 ]\n", + "Test set class label distribution: [0.3374613 0.34055728 0.32198142]\n", + "Train groups and their counts:: (array([0, 1, 3, 4, 5, 7, 8]), array([ 80, 99, 102, 114, 91, 92, 99]))\n", + "Test groups and their counts: (array([2, 6, 9]), array([122, 105, 96]))\n", + "Train size: 677\n", + "Test size: 323\n", + "Overlapping groups in train and test splits: 0\n", + "\n" + ] + } + ], + "source": [ + "# To ensure both groups are separated and the class labels are stratified, you can use StratifiedGroupKFold. Note that you cannot define a test set size here\n", + "\n", + "from sklearn.model_selection import StratifiedGroupKFold\n", + "sss = StratifiedGroupKFold(n_splits=3)\n", + "groups_counts = np.unique(groups, return_counts=True)\n", + "print(groups_counts)\n", + "print_report(sss, x, y_balanced, groups_balanced)" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "(array([0, 1]), array([ 70, 930]))\n", + "\n", + "Full dataset class label distribution: [0.932 0.039 0.029]\n", + "\n", + "Split: 0\n", + "Train y and their counts: (array([0, 1, 2]), array([666, 27, 18]))\n", + "Test y and their counts: (array([0, 1, 2]), array([266, 12, 11]))\n", + "Train set class label distribution: [0.93670886 0.03797468 0.02531646]\n", + "Test set class label distribution: [0.92041522 0.04152249 0.03806228]\n", + "Train groups and their counts:: (array([0, 2, 3, 4, 6, 7, 9]), array([ 80, 122, 102, 114, 105, 92, 96]))\n", + "Test groups and their counts: (array([1, 5, 8]), array([99, 91, 99]))\n", + "Train size: 711\n", + "Test size: 289\n", + "Overlapping groups in train and test splits: 0\n", + "\n", + "Split: 1\n", + "Train y and their counts: (array([0, 1, 2]), array([625, 26, 17]))\n", + "Test y and their counts: (array([0, 1, 2]), array([307, 13, 12]))\n", + "Train set class label distribution: [0.93562874 0.03892216 0.0254491 ]\n", + "Test set class label distribution: [0.9246988 0.03915663 0.03614458]\n", + "Train groups and their counts:: (array([0, 1, 3, 5, 6, 7, 8]), array([ 80, 99, 102, 91, 105, 92, 99]))\n", + "Test groups and their counts: (array([2, 4, 9]), array([122, 114, 96]))\n", + "Train size: 668\n", + "Test size: 332\n", + "Overlapping groups in train and test splits: 0\n", + "\n", + "Split: 2\n", + "Train y and their counts: (array([0, 1, 2]), array([573, 25, 23]))\n", + "Test y and their counts: (array([0, 1, 2]), array([359, 14, 6]))\n", + "Train set class label distribution: [0.92270531 0.04025765 0.03703704]\n", + "Test set class label distribution: [0.94722955 0.03693931 0.01583113]\n", + "Train groups and their counts:: (array([1, 2, 4, 5, 8, 9]), array([ 99, 122, 114, 91, 99, 96]))\n", + "Test groups and their counts: (array([0, 3, 6, 7]), array([ 80, 102, 105, 92]))\n", + "Train size: 621\n", + "Test size: 379\n", + "Overlapping groups in train and test splits: 0\n", + "\n" + ] + } + ], + "source": [ + "# Stratification works pretty well for the imbalanced class labels. Note that you cannot define a test set size here.\n", + "\n", + "sss = StratifiedGroupKFold(n_splits=3, shuffle=True, random_state=65)\n", + "groups_counts = np.unique(groups, return_counts=True)\n", + "print(f\"{groups_counts}\\n\")\n", + "print(f\"Full dataset class label distribution: {np.unique(y_imbalanced, return_counts=True)[1]/len(y_imbalanced)}\\n\")\n", + "print_report(sss, x, y_imbalanced, groups_balanced)" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "(array([0, 1]), array([ 70, 930]))\n", + "\n", + "Split: 0\n", + "Train y and their counts: (array([0, 1, 2]), array([64, 3, 3]))\n", + "Test y and their counts: (array([0, 1, 2]), array([868, 36, 26]))\n", + "Train set class label distribution: [0.91428571 0.04285714 0.04285714]\n", + "Test set class label distribution: [0.93333333 0.03870968 0.02795699]\n", + "Train groups and their counts:: (array([0]), array([70]))\n", + "Test groups and their counts: (array([1]), array([930]))\n", + "Train size: 70\n", + "Test size: 930\n", + "Overlapping groups in train and test splits: 10\n", + "\n", + "Split: 1\n", + "Train y and their counts: (array([0, 1, 2]), array([932, 39, 29]))\n", + "Test y and their counts: (array([], dtype=int64), array([], dtype=int64))\n", + "Train set class label distribution: [0.932 0.039 0.029]\n", + "Test set class label distribution: []\n", + "Train groups and their counts:: (array([0, 1]), array([ 70, 930]))\n", + "Test groups and their counts: (array([], dtype=int64), array([], dtype=int64))\n", + "Train size: 1000\n", + "Test size: 0\n", + "Overlapping groups in train and test splits: 0\n", + "\n", + "Split: 2\n", + "Train y and their counts: (array([0, 1, 2]), array([868, 36, 26]))\n", + "Test y and their counts: (array([0, 1, 2]), array([64, 3, 3]))\n", + "Train set class label distribution: [0.93333333 0.03870968 0.02795699]\n", + "Test set class label distribution: [0.91428571 0.04285714 0.04285714]\n", + "Train groups and their counts:: (array([1]), array([930]))\n", + "Test groups and their counts: (array([0]), array([70]))\n", + "Train size: 930\n", + "Test size: 70\n", + "Overlapping groups in train and test splits: 10\n", + "\n" + ] + } + ], + "source": [ + "sss = StratifiedGroupKFold(n_splits=3, shuffle=True, random_state=65)\n", + "groups_counts = np.unique(groups, return_counts=True)\n", + "print(f\"{groups_counts}\\n\")\n", + "print_report(sss, x, y_imbalanced, groups_imbalanced)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Due to the strong group label imbalance, splits resulted in an unwanted train/test ratio and you don't get a warning about it" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Interlude ##\n", + "\n", + "Let's summarize:\n", + "\n", + "| Feature | GroupShuffleSplit | GroupKFold | StratifiedGroupKFold |\n", + "|:-------------------------|:-----------------:|:-----------------:|:--------------------------|\n", + "| Shuffles Data? | ✅ | ❌ | ❌¹ |\n", + "| Keeps Groups Intact? | ✅ | ✅ | ✅ |\n", + "| Is the Split Stratified? | ❌ | ❌ | ✅ |\n", + "\n", + "¹ StratifiedGroupKFold does not create new random splits on each iteration like GroupShuffleSplit. Instead, it has a shuffle parameter that, if set to True, will randomly shuffle the order of groups once before partitioning them into k consecutive folds.\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's check how StratifiedGroupShuffleSplit works" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Groups and their counts: (array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), array([ 80, 99, 122, 102, 114, 91, 105, 92, 99, 96]))\n", + "Full dataset class label distribution: [0.932 0.039 0.029]\n", + "\n", + "Split: 0\n", + "Train y and their counts: (array([0, 1, 2]), array([768, 32, 24]))\n", + "Test y and their counts: (array([0, 1, 2]), array([164, 7, 5]))\n", + "Train set class label distribution: [0.93203883 0.03883495 0.02912621]\n", + "Test set class label distribution: [0.93181818 0.03977273 0.02840909]\n", + "Train groups and their counts:: (array([1, 2, 3, 4, 5, 6, 7, 8]), array([ 99, 122, 102, 114, 91, 105, 92, 99]))\n", + "Test groups and their counts: (array([0, 9]), array([80, 96]))\n", + "Train size: 824\n", + "Test size: 176\n", + "Overlapping groups in train and test splits: 0\n", + "\n", + "Split: 1\n", + "Train y and their counts: (array([0, 1, 2]), array([732, 29, 21]))\n", + "Test y and their counts: (array([0, 1, 2]), array([200, 10, 8]))\n", + "Train set class label distribution: [0.93606138 0.0370844 0.02685422]\n", + "Test set class label distribution: [0.91743119 0.04587156 0.03669725]\n", + "Train groups and their counts:: (array([0, 1, 3, 4, 5, 6, 7, 8]), array([ 80, 99, 102, 114, 91, 105, 92, 99]))\n", + "Test groups and their counts: (array([2, 9]), array([122, 96]))\n", + "Train size: 782\n", + "Test size: 218\n", + "Overlapping groups in train and test splits: 0\n", + "\n", + "Split: 2\n", + "Train y and their counts: (array([0, 1, 2]), array([733, 32, 22]))\n", + "Test y and their counts: (array([0, 1, 2]), array([199, 7, 7]))\n", + "Train set class label distribution: [0.93138501 0.04066074 0.02795426]\n", + "Test set class label distribution: [0.9342723 0.03286385 0.03286385]\n", + "Train groups and their counts:: (array([0, 1, 2, 3, 5, 6, 7, 9]), array([ 80, 99, 122, 102, 91, 105, 92, 96]))\n", + "Test groups and their counts: (array([4, 8]), array([114, 99]))\n", + "Train size: 787\n", + "Test size: 213\n", + "Overlapping groups in train and test splits: 0\n", + "\n" + ] + } + ], + "source": [ + "from splitter import StratifiedGroupShuffleSplit\n", + "\n", + "np.set_printoptions(legacy = '1.25')\n", + "sgss = StratifiedGroupShuffleSplit(n_splits=3, test_size = 0.22, random_state=43)\n", + "groups_counts = np.unique(groups_balanced, return_counts=True)\n", + "print(f\"Groups and their counts: {groups_counts}\")\n", + "print(f\"Full dataset class label distribution: {np.unique(y_imbalanced, return_counts=True)[1]/len(y_imbalanced)}\\n\")\n", + "print_report(sgss, x, y_imbalanced, groups_balanced)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We see that:\n", + "\n", + "1- For each split, we are getting different groups in the train and test sets,\n", + "2- Class labels are stratified with some small deviation (~<2%)\n", + "3- Groups are properly splitted\n", + "4- If the returned test size deviates more than 5% from the requested test size, we get a warning message.\n", + "\n", + "| Feature | GroupShuffleSplit | GroupKFold | StratifiedGroupKFold | StratifidGroupShuffleSplit\n", + "|:-------------------------|:-----------------:|:-----------------:|:--------------------------:|:--------------------------:|\n", + "| Shuffles Data? | ✅ | ❌ | ❌ | ✅ |\n", + "| Keeps Groups Intact? | ✅ | ✅ | ✅ | ✅ |\n", + "| Is the Split Stratified? | ❌ | ❌ | ✅ | ✅ |" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "One last example: if no train/test split is found, the algorithm will raise an error. In the following example, there is no way that we can split two groups such that the test set contains around 220 samples." + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Groups and their counts: (array([0, 1]), array([500, 500]))\n", + "Full dataset class label distribution: [0.932 0.039 0.029]\n", + "\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/peptid/Local_Documents/personal/git_repos/scikit_mol_bkav/scikit_mol/notebooks/../splitter.py:232: UserWarning: \n", + " \"Warning: All available groups are larger than the target test size. \n", + " The algorithm will still try to select a group that overshoots the target, \n", + " which may lead to a larger than requested test set, or an completely empty test set.\"\n", + " \n", + " warnings.warn(\n" + ] + }, + { + "ename": "RuntimeError", + "evalue": "Given the dataset, no train/test split could be found. Try increasing test_size", + "output_type": "error", + "traceback": [ + "\u001b[31m---------------------------------------------------------------------------\u001b[39m", + "\u001b[31mRuntimeError\u001b[39m Traceback (most recent call last)", + "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[24]\u001b[39m\u001b[32m, line 13\u001b[39m\n\u001b[32m 11\u001b[39m \u001b[38;5;28mprint\u001b[39m(\u001b[33mf\u001b[39m\u001b[33m\"\u001b[39m\u001b[33mGroups and their counts: \u001b[39m\u001b[38;5;132;01m{\u001b[39;00mgroups_counts\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m\"\u001b[39m)\n\u001b[32m 12\u001b[39m \u001b[38;5;28mprint\u001b[39m(\u001b[33mf\u001b[39m\u001b[33m\"\u001b[39m\u001b[33mFull dataset class label distribution: \u001b[39m\u001b[38;5;132;01m{\u001b[39;00mnp.unique(y_imbalanced,\u001b[38;5;250m \u001b[39mreturn_counts=\u001b[38;5;28;01mTrue\u001b[39;00m)[\u001b[32m1\u001b[39m]/\u001b[38;5;28mlen\u001b[39m(y_imbalanced)\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;130;01m\\n\u001b[39;00m\u001b[33m\"\u001b[39m)\n\u001b[32m---> \u001b[39m\u001b[32m13\u001b[39m \u001b[38;5;28;43;01mfor\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[43mi\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43m(\u001b[49m\u001b[43mtrain_index\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mtest_index\u001b[49m\u001b[43m)\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;129;43;01min\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[38;5;28;43menumerate\u001b[39;49m\u001b[43m(\u001b[49m\u001b[43msgss\u001b[49m\u001b[43m.\u001b[49m\u001b[43msplit\u001b[49m\u001b[43m(\u001b[49m\u001b[43mx\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43my_imbalanced\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mgroups_balanced\u001b[49m\u001b[43m)\u001b[49m\u001b[43m)\u001b[49m\u001b[43m:\u001b[49m\n\u001b[32m 14\u001b[39m \u001b[43m \u001b[49m\u001b[38;5;28;43;01mcontinue\u001b[39;49;00m\n", + "\u001b[36mFile \u001b[39m\u001b[32m~/Local_Documents/personal/git_repos/scikit_mol_bkav/scikit_mol/notebooks/../splitter.py:211\u001b[39m, in \u001b[36mStratifiedGroupShuffleSplit.split\u001b[39m\u001b[34m(self, X, y, groups)\u001b[39m\n\u001b[32m 185\u001b[39m \u001b[38;5;28;01mdef\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34msplit\u001b[39m(\u001b[38;5;28mself\u001b[39m, X, y, groups=\u001b[38;5;28;01mNone\u001b[39;00m):\n\u001b[32m 186\u001b[39m \u001b[38;5;250m \u001b[39m\u001b[33;03m\"\"\"Generates indices to split data into training and test set.\u001b[39;00m\n\u001b[32m 187\u001b[39m \n\u001b[32m 188\u001b[39m \u001b[33;03m Parameters\u001b[39;00m\n\u001b[32m (...)\u001b[39m\u001b[32m 209\u001b[39m \u001b[33;03m The testing set indices for that split.\u001b[39;00m\n\u001b[32m 210\u001b[39m \u001b[33;03m \"\"\"\u001b[39;00m\n\u001b[32m--> \u001b[39m\u001b[32m211\u001b[39m \u001b[38;5;28;01myield from\u001b[39;00m \u001b[38;5;28mself\u001b[39m._iter_indices(X, y, groups)\n", + "\u001b[36mFile \u001b[39m\u001b[32m~/Local_Documents/personal/git_repos/scikit_mol_bkav/scikit_mol/notebooks/../splitter.py:167\u001b[39m, in \u001b[36mStratifiedGroupShuffleSplit._iter_indices\u001b[39m\u001b[34m(self, X, y, groups)\u001b[39m\n\u001b[32m 161\u001b[39m test_indices = (\n\u001b[32m 162\u001b[39m np.concatenate([group_info[g_idx][\u001b[33m\"\u001b[39m\u001b[33mindices\u001b[39m\u001b[33m\"\u001b[39m] \u001b[38;5;28;01mfor\u001b[39;00m g_idx \u001b[38;5;129;01min\u001b[39;00m test_groups])\n\u001b[32m 163\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m test_groups\n\u001b[32m 164\u001b[39m \u001b[38;5;28;01melse\u001b[39;00m []\n\u001b[32m 165\u001b[39m )\n\u001b[32m 166\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28mlen\u001b[39m(test_indices) == \u001b[32m0\u001b[39m:\n\u001b[32m--> \u001b[39m\u001b[32m167\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mRuntimeError\u001b[39;00m(\u001b[33mf\u001b[39m\u001b[33m\"\u001b[39m\u001b[33mGiven the dataset, no train/test split could be found. Try increasing test_size\u001b[39m\u001b[33m\"\u001b[39m)\n\u001b[32m 168\u001b[39m all_indices = np.arange(n_samples)\n\u001b[32m 169\u001b[39m train_indices = np.setdiff1d(all_indices, test_indices, assume_unique=\u001b[38;5;28;01mTrue\u001b[39;00m)\n", + "\u001b[31mRuntimeError\u001b[39m: Given the dataset, no train/test split could be found. Try increasing test_size" + ] + } + ], + "source": [ + "# Let's create a dummy balanced dataset\n", + "x = np.random.rand(1000)\n", + "y_balanced = np.random.randint(0,3,1000)\n", + "groups_balanced = np.random.randint(0,2,1000)\n", + "\n", + "from splitter import StratifiedGroupShuffleSplit\n", + "\n", + "np.set_printoptions(legacy = '1.25')\n", + "sgss = StratifiedGroupShuffleSplit(n_splits=3, test_size = 0.22, random_state=43)\n", + "groups_counts = np.unique(groups_balanced, return_counts=True)\n", + "print(f\"Groups and their counts: {groups_counts}\")\n", + "print(f\"Full dataset class label distribution: {np.unique(y_imbalanced, return_counts=True)[1]/len(y_imbalanced)}\\n\")\n", + "for i, (train_index, test_index) in enumerate(sgss.split(x, y_imbalanced, groups_balanced)):\n", + " continue" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example Showcase ##\n", + "**Based heavily on the previous work of Ruel Cedeno, @cedenoruel**\n", + "\n", + "**!!! Seems to be working worse than the original notebook (https://github.com/cedenoruel/scikit-mol/blob/main/notebooks/12_scaffold_split_CV_and_hyperparameter_tuning.ipynb) when the full dataset is used. Maybe there's something wrong with my implementation.!!**" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": {}, + "outputs": [], + "source": [ + "# import the usual suspects\n", + "import os\n", + "import rdkit\n", + "from rdkit import Chem\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from time import time\n", + "import numpy as np\n", + "from sklearn.pipeline import Pipeline, make_pipeline\n", + "from sklearn.linear_model import Ridge\n", + "from sklearn.model_selection import train_test_split\n", + "\n", + "\n", + "#from scikit_mol package\n", + "\n", + "from scikit_mol.fingerprints import MorganFingerprintTransformer\n", + "\n", + "#to maintain reproducibility\n", + "random_state= 41 " + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": {}, + "outputs": [], + "source": [ + "full_set = True\n", + "\n", + "if full_set:\n", + " csv_file = \"../../tests/data/SLC6A4_active_excape_export.csv\"\n", + " if not os.path.exists(csv_file):\n", + " import urllib.request\n", + "\n", + " url = \"https://ndownloader.figshare.com/files/25747817\"\n", + " urllib.request.urlretrieve(url, csv_file)\n", + "else:\n", + " csv_file = \"../../tests/data/SLC6A4_active_excapedb_subset.csv\"\n", + "\n", + "data = pd.read_csv(csv_file) \n", + "#Add ROMol column to the dataframe\n", + "data[\"ROMol\"] = data.SMILES.apply(Chem.MolFromSmiles)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 2: Standardize molecules\n", + "\n", + "One key advantage of scikit-mol is it allows you to save/pickle models that **work directly on rdkit Mol object**.\n", + "\n", + "This solves the problem of having to generate features/descriptors externally with the risk of being incompatible with the saved model (that you may have built few months ago). " + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "metadata": {}, + "outputs": [], + "source": [ + "# Probably the recommended way would be to prestandardize the data if there's no changes to the transformer,\n", + "# and then add the standardizer in the inference pipeline.\n", + "\n", + "from scikit_mol.standardizer import Standardizer\n", + "\n", + "standardizer = Standardizer()\n", + "\n", + "data[\"ROMol\"] = standardizer.transform(data[\"ROMol\"]).flatten()" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
Ambit_InchiKeyOriginal_Entry_IDEntrez_IDActivity_FlagpXC50DBOriginal_Assay_IDTax_IDGene_SymbolOrtholog_GroupSMILESROMol
0AZMKBJHIXZCVNL-BXKDBHETNA-N445906436532A5.68382pubchem3932609606SLC6A44061FC1=CC([C@@H]2O[C@H](CC2)CN)=C(OC)C=C1<rdkit.Chem.rdchem.Mol object at 0x1333ecc80>
1AZMKBJHIXZCVNL-UHFFFAOYNA-N114923056532A5.16210pubchem3932589606SLC6A44061FC1=CC(C2OC(CC2)CN)=C(OC)C=C1<rdkit.Chem.rdchem.Mol object at 0x1333eccf0>
2AZOHUEDNMOIDOC-GETDIYNLNA-N444193406532A6.66354pubchem2760599606SLC6A44061FC1=CC=C(C[C@H]2C[C@@H](N(CC2)CC=C)CCCNC(=O)NC...<rdkit.Chem.rdchem.Mol object at 0x1333ecd60>
3AZSKJKSQZWHDOK-VJSLDGLSNA-NCHEMBL10807456532A6.96000chembl206170829606SLC6A44061C=1C=C(C=CC1)C2=CC(=C(N2CC(C)C)C)C(NCCCN3CCN(C...<rdkit.Chem.rdchem.Mol object at 0x1333ecdd0>
4AZTPZTRJVCAAMX-UHFFFAOYNA-NCHEMBL5783466532A8.00000chembl205969349606SLC6A44061C1=CC=C2C(=C1)C=C(C(N(C3CCNCC3)C4CCC4)=O)C=C2<rdkit.Chem.rdchem.Mol object at 0x1333ece40>
.......................................
7223ZZHKHRXDQLQSFW-HHHXNRCGNA-NCHEMBL2823806532A5.74000chembl205325809606SLC6A44061C=1C=CC(C(C=2C=CC=CC2)OCCN3CCN(CC3)C[C@@H](CC4...<rdkit.Chem.rdchem.Mol object at 0x1334c80b0>
7224ZZHKHRXDQLQSFW-MHZLTWQENA-NCHEMBL2814925553A5.67000chembl2019805010116SLC6A44061C=1C=CC(C(C=2C=CC=CC2)OCCN3CCN(CC3)C[C@H](CC4=...<rdkit.Chem.rdchem.Mol object at 0x1334c8120>
7225ZZHKHRXDQLQSFW-MHZLTWQENA-NCHEMBL281496532A5.66000chembl205325809606SLC6A44061C=1C=CC(C(C=2C=CC=CC2)OCCN3CCN(CC3)C[C@H](CC4=...<rdkit.Chem.rdchem.Mol object at 0x1334c8190>
7226ZZJGNQRWIXQQDJ-CPLJGATDNA-N444193066532A5.26241pubchem2760599606SLC6A44061FC1=CC=C(C[C@H]2C[C@@H](N(CC2)C(=O)C)CCCNC(=O)...<rdkit.Chem.rdchem.Mol object at 0x1334c8200>
7227ZZRHLICOWQQNJL-UHFFFAOYNA-NCHEMBL16838756532A6.81000chembl207269269606SLC6A44061C1CCCCC1(C2=CC=C(C(=C2)Cl)Cl)CN(CC)C<rdkit.Chem.rdchem.Mol object at 0x1334c8270>
\n", + "

7228 rows × 12 columns

\n", + "
" + ], + "text/plain": [ + " Ambit_InchiKey Original_Entry_ID Entrez_ID Activity_Flag \\\n", + "0 AZMKBJHIXZCVNL-BXKDBHETNA-N 44590643 6532 A \n", + "1 AZMKBJHIXZCVNL-UHFFFAOYNA-N 11492305 6532 A \n", + "2 AZOHUEDNMOIDOC-GETDIYNLNA-N 44419340 6532 A \n", + "3 AZSKJKSQZWHDOK-VJSLDGLSNA-N CHEMBL1080745 6532 A \n", + "4 AZTPZTRJVCAAMX-UHFFFAOYNA-N CHEMBL578346 6532 A \n", + "... ... ... ... ... \n", + "7223 ZZHKHRXDQLQSFW-HHHXNRCGNA-N CHEMBL282380 6532 A \n", + "7224 ZZHKHRXDQLQSFW-MHZLTWQENA-N CHEMBL28149 25553 A \n", + "7225 ZZHKHRXDQLQSFW-MHZLTWQENA-N CHEMBL28149 6532 A \n", + "7226 ZZJGNQRWIXQQDJ-CPLJGATDNA-N 44419306 6532 A \n", + "7227 ZZRHLICOWQQNJL-UHFFFAOYNA-N CHEMBL1683875 6532 A \n", + "\n", + " pXC50 DB Original_Assay_ID Tax_ID Gene_Symbol \\\n", + "0 5.68382 pubchem 393260 9606 SLC6A4 \n", + "1 5.16210 pubchem 393258 9606 SLC6A4 \n", + "2 6.66354 pubchem 276059 9606 SLC6A4 \n", + "3 6.96000 chembl20 617082 9606 SLC6A4 \n", + "4 8.00000 chembl20 596934 9606 SLC6A4 \n", + "... ... ... ... ... ... \n", + "7223 5.74000 chembl20 532580 9606 SLC6A4 \n", + "7224 5.67000 chembl20 198050 10116 SLC6A4 \n", + "7225 5.66000 chembl20 532580 9606 SLC6A4 \n", + "7226 5.26241 pubchem 276059 9606 SLC6A4 \n", + "7227 6.81000 chembl20 726926 9606 SLC6A4 \n", + "\n", + " Ortholog_Group SMILES \\\n", + "0 4061 FC1=CC([C@@H]2O[C@H](CC2)CN)=C(OC)C=C1 \n", + "1 4061 FC1=CC(C2OC(CC2)CN)=C(OC)C=C1 \n", + "2 4061 FC1=CC=C(C[C@H]2C[C@@H](N(CC2)CC=C)CCCNC(=O)NC... \n", + "3 4061 C=1C=C(C=CC1)C2=CC(=C(N2CC(C)C)C)C(NCCCN3CCN(C... \n", + "4 4061 C1=CC=C2C(=C1)C=C(C(N(C3CCNCC3)C4CCC4)=O)C=C2 \n", + "... ... ... \n", + "7223 4061 C=1C=CC(C(C=2C=CC=CC2)OCCN3CCN(CC3)C[C@@H](CC4... \n", + "7224 4061 C=1C=CC(C(C=2C=CC=CC2)OCCN3CCN(CC3)C[C@H](CC4=... \n", + "7225 4061 C=1C=CC(C(C=2C=CC=CC2)OCCN3CCN(CC3)C[C@H](CC4=... \n", + "7226 4061 FC1=CC=C(C[C@H]2C[C@@H](N(CC2)C(=O)C)CCCNC(=O)... \n", + "7227 4061 C1CCCCC1(C2=CC=C(C(=C2)Cl)Cl)CN(CC)C \n", + "\n", + " ROMol \n", + "0 \n", + "1 \n", + "2 \n", + "3 \n", + "4 \n", + "... ... \n", + "7223 \n", + "7224 \n", + "7225 \n", + "7226 \n", + "7227 \n", + "\n", + "[7228 rows x 12 columns]" + ] + }, + "execution_count": 28, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "data" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 3: Use scaffold split to obtain train and test sets\n", + "\n", + "In a conventional random split, you would use the following \n", + "```python\n", + "X_train, X_test, y_train, y_test = train_test_split(X = data.ROMol, y = data.pXC50, test_size=0.2)\n", + "```\n", + "To perform a scaffold split, simply use ```data[\"scaffold_ID\"], _ = data['scaffold_smiles'].factorize()``` to add a column containing the **scaffold ID** then pass it to the parameter ```groups``` in the ```train_test_group_split``` " + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "metadata": {}, + "outputs": [], + "source": [ + "from sklearn.pipeline import Pipeline\n", + "from conversions import SmilesToMolTransformer, MolToScaffoldTransformer, MurckoScaffoldGenerator" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "metadata": {}, + "outputs": [], + "source": [ + "pipeline = Pipeline([('mol_transformer', SmilesToMolTransformer(safe_inference_mode = True)), ('scaffold_transformer', \n", + " MolToScaffoldTransformer(safe_inference_mode=True, scaffold_generator=MurckoScaffoldGenerator(include_chirality=False, make_generic=False)))])" + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "metadata": {}, + "outputs": [], + "source": [ + "scaffold = pipeline.transform(data['SMILES'])" + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "metadata": {}, + "outputs": [], + "source": [ + "scaffold_smiles = [Chem.MolToSmiles(i[0]) for i in scaffold]\n", + "data['scaffold_smiles'] = scaffold_smiles " + ] + }, + { + "cell_type": "code", + "execution_count": 33, + "metadata": {}, + "outputs": [], + "source": [ + "data[\"scaffold_ID\"], _ = data['scaffold_smiles'].factorize()" + ] + }, + { + "cell_type": "code", + "execution_count": 34, + "metadata": {}, + "outputs": [], + "source": [ + "from splitter import train_test_group_split\n", + "x_train, x_test, y_train, y_test, groups_train, groups_test = train_test_group_split(data.ROMol, data.pXC50, data.scaffold_ID, stratify=True)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 4: Build a pipeline and hyperparameter search space" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "For demonstration purposes, we will use a similar pipeline as the previous tutorial in hyperparameter tuning." + ] + }, + { + "cell_type": "code", + "execution_count": 35, + "metadata": {}, + "outputs": [], + "source": [ + "moltransformer = MorganFingerprintTransformer()\n", + "regressor = Ridge(random_state=random_state)\n", + "optimization_pipe = make_pipeline(moltransformer, regressor)\n", + "\n", + "\n", + "#from scipy.stats import loguniform\n", + "\n", + "\n", + "param_grid = {\n", + " \"ridge__alpha\": [0.01,0.1,4,8],\n", + " \"morganfingerprinttransformer__fpSize\": [512,1024,2048,4096],\n", + "}\n", + "#\"morganfingerprinttransformer__radius\": [2, 3]\n", + "# \"morganfingerprinttransformer__useCounts\": [True, False]\n", + "# \"morganfingerprinttransformer__useFeatures\": [True, False]," + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 5: Use the GroupSplitCV in Hyperparameter Tuning\n", + "\n", + "Using ```GroupSplitCV``` allows you to perform hyperparameter tuning such that the validation set doesn't contain any **groups** present in the training set. In this particular example, our groups are **Bemis-Murcko scaffolds**. \n", + "\n", + "In principle, this would yield optimal hyperparameters that can better generalize to unseen molecular architectures. \n", + "\n", + "Using ```groups``` as a parameter for hyperparameter tuning is flexible as it gives you the freedom to create your own grouping algorithm. For instance, if you are working with a chemical series having the same scaffolds, then other grouping procedure is needed (k-nearest neighbor, Butina clustering, etc).\n", + "\n", + "```GroupSplitCV``` has the following arguments:\n", + "\n", + "n_splits <- number of splits/folds \n", + "\n", + "n_repeats <- number of reshuffling repetitions\n", + "\n", + "X,y <- as usual\n", + "\n", + "groups <- groupID such as scaffold_ID or cluster_ID, the validation set will not contain any group in common with the training set in each cycle\n" + ] + }, + { + "cell_type": "code", + "execution_count": 36, + "metadata": {}, + "outputs": [], + "source": [ + "from splitter import GroupSplitCV\n", + "cv_scaffold = GroupSplitCV(n_splits=5, test_size=0.2, random_state=random_state)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Pass the ```cv_scaffold``` to the ```cv``` parameter of any SearchCV of scikit-learn, in this case we use the ```GridSearchCV``` to minimize the effect of randomness " + ] + }, + { + "cell_type": "code", + "execution_count": 37, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Runtime: 236.86\n" + ] + } + ], + "source": [ + "from sklearn.model_selection import GridSearchCV\n", + "search_scaffold = GridSearchCV(optimization_pipe, param_grid=param_grid, cv=cv_scaffold)\n", + "\n", + "t0 = time()\n", + "search_scaffold.fit(x_train, y_train, groups=groups_train)\n", + "t1 = time()\n", + "print(f\"Runtime: {t1-t0:0.2F}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 38, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0.4337796417083567" + ] + }, + "execution_count": 38, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "#Test performance\n", + "from sklearn.metrics import r2_score\n", + "y_pred_scaffold = search_scaffold.best_estimator_.predict(x_test)\n", + "r2_scaffold = r2_score(y_test,y_pred_scaffold)\n", + "r2_scaffold" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Analysis: Scaffold vs Random Split" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To understand the impact of scaffold splits, let's retrain the same model but this time, using random splits in cross validation." + ] + }, + { + "cell_type": "code", + "execution_count": 39, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Runtime: 639.33\n" + ] + } + ], + "source": [ + "from sklearn.base import clone\n", + "from sklearn.model_selection import RepeatedKFold\n", + "\n", + "cv_random = RepeatedKFold(n_splits=5, n_repeats=4, random_state=random_state)\n", + "#We use the same parameters n_splits and n_repeats so that it is comparable with the scaffold split\n", + "\n", + "optimization_pipe_random = clone(optimization_pipe)\n", + "\n", + "search_random = GridSearchCV(optimization_pipe_random, param_grid=param_grid, cv=cv_random)\n", + "\n", + "t0 = time()\n", + "search_random.fit(x_train,y_train)\n", + "t1 = time()\n", + "print(f\"Runtime: {t1-t0:0.2F}\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": 47, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'morganfingerprinttransformer__fpSize': 4096, 'ridge__alpha': 8}" + ] + }, + "execution_count": 47, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "search_random.best_params_" + ] + }, + { + "cell_type": "code", + "execution_count": 48, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'morganfingerprinttransformer__fpSize': 4096, 'ridge__alpha': 8}" + ] + }, + "execution_count": 48, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "search_scaffold.best_params_" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "Notice that the **optimal hyperparameters may be different** depending on the CV splitting approach.\n", + "\n", + "To determine whether this difference is statistically significant, we will perform further analysis." + ] + }, + { + "cell_type": "code", + "execution_count": 49, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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uZni4mJlZGSUKlVms1mxsbFKT08vd7+LFy+qVatWCgkJ0YMPPqhDhw6Vu21SUpICAwPtj5CQEIeyAgCA2sHh0nP8+HF16tSp3PUdO3bU8ePHHTr22bNnVVJScsNIjcViUU5OTpn7tGvXTikpKfrwww/1zjvvyGq16o477tD3339f5vYTJ05Ufn6+/ZGVleVQVgAAUDvUc3THq1ev6sqVK+Wuv3Llyk3XO1tMTIxiYmLsz++44w516NBBCxcu1IwZM27Y3tvbW97e3jWWDwAAuJbDIz0RERHaunVrueu3bNmi8PBwh44dHBwsDw+PG25jkZubq6ZNm1boGJ6enuratau++eYbhzIAAIC6xeHSM3ToUG3ZskUvv/yyiouL7cuvXr2qKVOmaMuWLRo2bJhDx/by8lJ0dLTS0tLsy6xWq9LS0kqN5txMSUmJvvzySzVr1syhDAAAoG5x+IrMV69e1YABA/T3v/9djRo1Uvv27SVJR44c0blz59S7d29t3bpVXl5eDgVbvXq14uPjtXDhQvXo0UPJycl6//33deTIEVksFo0YMUItWrRQUlKSJGn69Om6/fbb1aZNG/3444964403tGHDBmVkZCgyMvJXX48rMgMAUPtU5vPb4Tk9np6e2rJli/785z/r3Xfftd98NCIiQhMmTNC4cePk6enp6OE1ZMgQ5eXlafLkycrJyVFUVJRSU1Ptk5szMzNlNv97oOr8+fMaNWqUcnJydMsttyg6Olqff/55hQoPAACo+xwe6alrGOkBAKD2qZGRnrJcu3ZNH374oc6dO6f777+/wpOOAQAAqpvDE5nHjx+v7t2725/bbDb1799fjzzyiJ588kl16tRJJ06ccEpIAACAqnK49KSmpqp379725x9//LF27dqlF198Ue+++64kadasWVVPCAAA4AQOn97KysoqdRuKjz/+WL/5zW/sRefQoUNauXJl1RMCAAA4gcMjPcXFxapX79+dafv27aXuldW6dWudPn26aukAAACcxOHSExISYr/556FDh/Ttt9+qT58+9vVnzpyRn59f1RMCAAA4gcOntx599FHNmDFDZ86c0aFDhxQQEKB7773Xvv7AgQMO34YCAADA2Rwe6Zk4caJGjhyp9PR0mUwmLV++XA0bNpQk5efn66OPPlL//v2dlRMAAKBKquXihFarVRcuXJCvr2+Vrspc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pKdjx45as2aNJMlms2ndunUKCQlxTkoAAIAqcrj0xMbGau3atSopKZEkPfnkk0pNTVV4eLjatm2rbdu26fHHH3daUAAAgKpw+CvrEyZM0PDhw+1fUx8zZoyuXLmid955Rx4eHho1apTGjx/vtKB1RW5urvLz810dw/BOnTpV6k+4XmBgoCwWi6tjAKjDTLbK3EG0DisoKFBgYKDy8/MVEBBQLa+Rm5urPwwfoavFRdVyfKA28/Ty1jsrllN8AFRKZT6/HR7pKSkpsX9rCxWTn5+vq8VFuty6j6w+ga6OA7gN85V86du/Kz8/n9IDoNo4XHqaN2+u3//+9xoxYoSioqKcGKnus/oEytog2NUxAAAwFIcnMrdu3VrJycmKjo5Wly5dNGfOHOXm5jozGwAAgNM4XHrS09N17Ngx/e///q8uXLigF154QSEhIbrvvvv0wQcf2C9UCAAA4A4cLj2S1KZNG82YMUPffvuttm/fruHDh2v37t0aMmSImjZtqqeeespZOQEAAKqkSqXnl/r06aMlS5YoJydHixcvltVq1eLFi511eAAAgCpxeCJzWT799FMtX75c69at08WLFxUUFOTMwwMAADisyqXnyJEjWr58uVauXKnvv/9e9erV08CBAxUfH6/77rvPGRkBAACqzOHSM2/ePC1fvlwZGRmy2Wy67bbb9Pzzz2vYsGEKDubr2AAAwL04XHqeffZZNW3aVM8//7zi4+PVsWNHZ+YCAABwKodLz8aNGzVgwACZzU6bCw0AAFBtHC4999xzjzNzAAAAVCuGaQAAgCFQegAAgCFQegAAgCFQegAAgCFQegAAgCFQegAAgCFUqPSsWbNGzz33nFJSUnTt2rVS6377299WSzAAAABn+tXSM2/ePI0dO1aXLl3SG2+8oV69euncuXP29bt27arWgAAAAM5QodKzefNmLVq0SF9++aWioqLUr18/e/Gx2WzVHhIAAKCqfrX0nD59Wl26dJEk1atXTwsXLlS/fv1011136YcffpDJZKr2kAAAAFX1q6UnODhY3333Xallc+bM0V133aW77rrrhjk+AAAA7uhXS0///v21dOnSG5YnJyerb9++unLlSnXkkiTNnz9fYWFh8vHxUc+ePbV3795yt123bp26deumhg0bqkGDBoqKitKKFSuqLRsAAKhdfvWGo/PmzSt3NOcvf/mLXnjhBaeHkqTVq1crMTFRCxYsUM+ePZWcnKy4uDgdPXpUTZo0uWH7Ro0aadKkSWrfvr28vLz0t7/9TQkJCWrSpIni4uKqJSMAAKg9fnWkx8vLS76+vuWuDw0NdWqg6+bMmaNRo0YpISFBkZGRWrBggXx9fZWSklLm9n379tVDDz2kDh06KDw8XOPGjVPnzp21e/fuaskHAABqF7e8OGFxcbEyMjIUGxtrX2Y2mxUbG6v09PRf3d9msyktLU1Hjx7VnXfeWeY2RUVFKigoKPUAAAB1V6VLz4YNGzR48GANGDBA06ZN04ULF8rcbvfu3Xr++ecdCnX27FmVlJTIYrGUWm6xWJSTk1Pufvn5+fLz85OXl5d++9vfau7cubr77rvL3DYpKUmBgYH2R0hIiENZAQBA7fCrc3p+afXq1Ro2bJj92jzbtm3T+++/r88//1yBgYHavn273nvvPX300UfKy8uTJM2ePdv5qcvh7++vgwcP6uLFi0pLS1NiYqJat26tvn373rDtxIkTlZiYaH9eUFBA8QEAoA6rVOmZM2eOmjZtqpUrVyo8PFwff/yxXnzxRc2aNUvp6enatWuXbDabmjdvrieeeEL33XefQ6GCg4Pl4eGh3NzcUstzc3PVtGnTcvczm81q06aNJCkqKkqHDx9WUlJSmaXH29tb3t7eDuUDAAC1T6VKz9GjRzVhwgR7iRgzZowuXLigl19+WVarVU888YRGjx6t6OjoKoXy8vJSdHS00tLSNGjQIEmS1WpVWlqaxo4dW+HjWK1WFRUVVSlLdTBf/tHVEQC3wnsCQE2oVOkp6xTQgw8+qIkTJ2rChAl69dVXnRYsMTFR8fHx6tatm3r06KHk5GQVFhYqISFBkjRixAi1aNFCSUlJkn6ao9OtWzeFh4erqKhIGzdu1IoVK/TWW285LZOz1P9up6sjAABgOJUqPZJuuO1E48aNJUm9e/d2TqKfDRkyRHl5eZo8ebJycnIUFRWl1NRU++TmzMxMmc3/noddWFioMWPG6Pvvv1f9+vXVvn17vfPOOxoyZIhTcznD5d/cKWv9hq6OAbgN8+Uf+c8AgGpX6dLzySefKCAgQF27dlWLFi3sy6tjfszYsWPLPZ21Y8eOUs9feeUVvfLKK07PUB2s9RvK2iDY1TEAADCUSpee9957T6tWrZL00yhPZGSkTCaT9u/fr/bt26t58+ZODwkAAFBVlSo9+fn52r9/v/bv36+MjAzt379fO3fulM1m00svvaSXXnpJwcHB6tKli6KiotS1a1cNHTq0urIDAABUWKVKj7+/v/r06aM+ffrYlxUWFurgwYP2EpSRkaHt27dr27ZtMplMlB4AAOAWKn166z81aNBAvXr1Uq9evezLLl++rH/+85/av39/VQ8PAADgFFUuPWWpX7++br/9dt1+++3VcXgAAIBKc8sbjgIAADgbpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABgCpQcAABhCPVcHMCLzlXxXRwDcCu8JADWB0lODAgMD5enlLX37d1dHAdyOp5e3AgMDXR0DQB1G6alBFotF76xYrvx8/lfraqdOndLMmTM1adIktWrVytVxoJ/+U2CxWFwdA0AdRumpYRaLhX/Y3UirVq0UERHh6hgAgBrARGYAAGAIlB4AAGAIlB4AAGAIlB4AAGAIbl165s+fr7CwMPn4+Khnz57au3dvudsuXrxYvXv31i233KJbbrlFsbGxN90eAAAYi9uWntWrVysxMVFTpkzR/v371aVLF8XFxenMmTNlbr9jxw4NHTpU27dvV3p6ukJCQjRgwAD961//quHkAADAHblt6ZkzZ45GjRqlhIQERUZGasGCBfL19VVKSkqZ269cuVJjxoxRVFSU2rdvr//7v/+T1WpVWlpaDScHAADuyC1LT3FxsTIyMhQbG2tfZjabFRsbq/T09Aod49KlS7p69aoaNWpU5vqioiIVFBSUegAAgLrLLUvP2bNnVVJScsNF/CwWi3Jycip0jJdeeknNmzcvVZx+KSkpSYGBgfZHSEhIlXMDAAD35Zalp6pmzZqlVatWaf369fLx8Slzm4kTJyo/P9/+yMrKquGUAACgJrnlbSiCg4Pl4eGh3NzcUstzc3PVtGnTm+775ptvatasWdq2bZs6d+5c7nbe3t7y9vZ2Sl4AAOD+3HKkx8vLS9HR0aUmIV+flBwTE1Pufq+//rpmzJih1NRUdevWrSaiAgCAWsItR3okKTExUfHx8erWrZt69Oih5ORkFRYWKiEhQZI0YsQItWjRQklJSZKk1157TZMnT9a7776rsLAw+9wfPz8/+fn5ueznAAAA7sFtS8+QIUOUl5enyZMnKycnR1FRUUpNTbVPbs7MzJTZ/O+BqrfeekvFxcX67//+71LHmTJliqZOnVqT0QEAgBty29IjSWPHjtXYsWPLXLdjx45Sz0+ePFn9gQAAQK3llnN6AAAAnI3SAwAADIHSAwAADIHSAwAADIHSAwAADIHSAwAADIHSAwAADIHSAwAADIHSAwAADIHSAwAADIHSAwAADIHSAwAADIHSAwAADIHSAwAADIHSAwAADIHSAwAADIHSAwAADIHSAwAADIHSAwAADIHSAwAADKGeqwOg9rly5YoyMzNdHaNKTp06VerP2i40NFQ+Pj6ujgEAbo3Sg0rLzMzU6NGjXR3DKWbOnOnqCE6xaNEiRUREuDoGALg1Sg8qLTQ0VIsWLXJ1DPxCaGioqyMAgNuj9KDSfHx8GFUAANQ6TGQGAACGQOkBAACGQOkBAACGQOkBAACGQOkBAACGQOkBAACGQOkBAACGQOkBAACGQOkBAACGQOkBAACGQOkBAACGQOkBAACGQOkBAACGwF3Wf2az2SRJBQUFLk4CAAAq6vrn9vXP8Zuh9PzswoULkqSQkBAXJwEAAJV14cIFBQYG3nQbk60i1cgArFarsrOz5e/vL5PJ5Oo4qGYFBQUKCQlRVlaWAgICXB0HgBPx/jYWm82mCxcuqHnz5jKbbz5rh5Gen5nNZrVs2dLVMVDDAgIC+EcRqKN4fxvHr43wXMdEZgAAYAiUHgAAYAiUHhiSt7e3pkyZIm9vb1dHAeBkvL9RHiYyAwAAQ2CkBwAAGAKlBwAAGAKlBwAAGAKlB4azYcMGtWnTRh4eHnruuefKXfZrwsLClJycfNNtTCaTNmzYUKW8ABw3cuRIDRo0yNUx4Ca4OCEM58knn1RCQoKeffZZ+fv7l7sMAFC3UHpgKBcvXtSZM2cUFxen5s2bl7sMQM0pLi6Wl5eXq2PAADi9Bbe0Zs0aderUSfXr11dQUJBiY2NVWFgoSUpJSVHHjh3l7e2tZs2aaezYsfb95syZo06dOqlBgwYKCQnRmDFjdPHiRUnSjh077KM4/fr1k8lkKneZJK1du9b+OmFhYZo9e/ZNMx8/flx33nmnfHx8FBkZqa1btzr71wLUCX379tXYsWP13HPPKTg4WHFxcTd970rS0qVL1bBhQ23evFkdOnSQn5+f7rnnHp0+fdq+TUlJiRITE9WwYUMFBQVp/PjxN9x5u6ioSM8++6yaNGkiHx8f/dd//Zf27dtnX79jxw6ZTCZt3rxZXbt2Vf369dWvXz+dOXNGmzZtUocOHRQQEKBhw4bp0qVL1f/LglNReuB2Tp8+raFDh+qxxx7T4cOHtWPHDj388MOy2Wx666239Mwzz2j06NH68ssv9dFHH6lNmzb2fc1ms/7yl7/o0KFDWrZsmT799FONHz9eknTHHXfo6NGjkn4qNKdPny53WUZGhh555BE9+uij+vLLLzV16lS9/PLLWrp0aZmZrVarHn74YXl5eWnPnj1asGCBXnrpper9RQG12LJly+Tl5aXPPvtMCxYsuOl797pLly7pzTff1IoVK7Rz505lZmbqhRdesK+fPXu2li5dqpSUFO3evVvnzp3T+vXrSx1j/PjxWrt2rZYtW6b9+/erTZs2iouL07lz50ptN3XqVM2bN0+ff/65srKy9Mgjjyg5OVnvvvuuPvnkE23ZskVz586tvl8QqocNcDMZGRk2SbaTJ0/esK558+a2SZMmVfhYH3zwgS0oKMj+/Pz58zZJtu3bt9902bBhw2x33313qWO9+OKLtsjISPvzVq1a2f785z/bbDabbfPmzbZ69erZ/vWvf9nXb9q0ySbJtn79+grnBYygT58+tq5du950m/9877799ts2SbZvvvnGvmz+/Pk2i8Vif96sWTPb66+/bn9+9epVW8uWLW0PPvigzWaz2S5evGjz9PS0rVy50r5NcXGxrXnz5vb9tm/fbpNk27Ztm32bpKQkmyTbiRMn7MuefPJJW1xcXCV/crgaIz1wO126dFH//v3VqVMnDR48WIsXL9b58+d15swZZWdnq3///uXuu23bNvXv318tWrSQv7+/hg8frh9++KHSw9CHDx9Wr169Si3r1auXjh8/rpKSkjK3DwkJKTUnKCYmplKvCRhJdHR0qecVee/6+voqPDzc/rxZs2Y6c+aMJCk/P1+nT59Wz5497evr1aunbt262Z+fOHFCV69eLfXe9vT0VI8ePXT48OFSeTp37mz/u8Vika+vr1q3bl1q2fXXRu1B6YHb8fDw0NatW7Vp0yZFRkZq7ty5ateunXJzc2+638mTJ3Xfffepc+fOWrt2rTIyMjR//nxJP02UBOA+GjRoYP97Rd+7np6epY5hMplumLPjLL98LZPJVOZrW63WanltVB9KD9ySyWRSr169NG3aNB04cEBeXl7aunWrwsLClJaWVuY+GRkZslqtmj17tm6//XZFREQoOzvbodfv0KGDPvvss1LLPvvsM0VERMjDw6PM7bOyskpNqvzHP/7h0GsDRuOM925gYKCaNWumPXv22Jddu3ZNGRkZ9ufh4eH2eUTXXb16Vfv27VNkZGTVfxC4Pb6yDrezZ88epaWlacCAAWrSpIn27NmjvLw8dejQQVOnTtVTTz2lJk2aaODAgbpw4YI+++wz/fGPf1SbNm109epVzZ07V/fff799gqQjnn/+eXXv3l0zZszQkCFDlJ6ernnz5umvf/1rmdvHxsYqIiJC8fHxeuONN1RQUKBJkyZV5dcAGIaz3rvjxo3TrFmz1LZtW7Vv315z5szRjz/+aF/foEEDPf3003rxxRfVqFEjhYaG6vXXX9elS5f0+OOPO/EngrtipAduJyAgQDt37tS9996riIgI/elPf9Ls2bM1cOBAxcfHKzk5WX/961/VsWNH3XfffTp+/Likn+YCzZkzR6+99ppuvfVWrVy5UklJSQ5luO222/T+++9r1apVuvXWWzV58mRNnz5dI0eOLHN7s9ms9evX6/Lly+rRo4eeeOIJzZw509FfAWAoznrvPv/88xo+fLji4+MVExMjf39/PfTQQ6W2mTVrln73u99p+PDhuu222/TNN99o8+bNuuWWW5z148CNmWzVdUIUAADAjTDSAwAADIHSAwAADIHSAwAADIHSAwAADIHSAwAADIHSAwAADIHSAwAADIHSAwAADIHSA8BQ+vbtq+eee87+PCwsTMnJyS7LA6DmcO8tAIa2b9++Unf8NplMWr9+vQYNGuS6UACqBaUHgKE1btzY1REA1BBObwGoddasWaNOnTqpfv36CgoKUmxsrAoLCzVy5EgNGjRI06ZNU+PGjRUQEKCnnnpKxcXF5R7rl6e3wsLCJEkPPfSQTCaT/TmAuoGRHgC1yunTpzV06FC9/vrreuihh3ThwgXt2rVL1++dnJaWJh8fH+3YsUMnT55UQkKCgoKCKnTX+3379qlJkyZ6++23dc8998jDw6O6fxwANYjSA6BWOX36tK5du6aHH35YrVq1kiR16tTJvt7Ly0spKSny9fVVx44dNX36dL344ouaMWOGzOabD25fP9XVsGFDNW3atPp+CAAuwektALVKly5d1L9/f3Xq1EmDBw/W4sWLdf78+VLrfX197c9jYmJ08eJFZWVluSIuADdC6QFQq3h4eGjr1q3atGmTIiMjNXfuXLVr107fffedq6MBcHOUHgC1jslkUq9evTRt2jQdOHBAXl5eWr9+vSTpn//8py5fvmzf9h//+If8/PwUEhJSoWN7enqqpKSkWnIDcC1KD4BaZc+ePXr11Vf1xRdfKDMzU+vWrVNeXp46dOggSSouLtbjjz+ur7/+Whs3btSUKVM0duzYX53Pc11YWJjS0tKUk5NT6rQZgNqP0gOgVgkICNDOnTt17733KiIiQn/60580e/ZsDRw4UJLUv39/tW3bVnfeeaeGDBmiBx54QFOnTq3w8WfPnq2tW7cqJCREXbt2raafAoArmGzXv+cJALXcyJEj9eOPP2rDhg2ujgLADTHSAwAADIHSAwAADIHTWwAAwBAY6QEAAIZA6QEAAIZA6QEAAIZA6QEAAIZA6QEAAIZA6QEAAIZA6QEAAIZA6QEAAIbw/79SVse8MfA/AAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import seaborn as sns\n", + "import matplotlib.pyplot as plt\n", + "df_cv_compare = pd.DataFrame()\n", + "\n", + "df_scaffold_cv_results = pd.DataFrame(search_scaffold.cv_results_)\n", + "df_random_cv_results = pd.DataFrame(search_random.cv_results_)\n", + "\n", + "\n", + "#get the row corresponding to the best hyperparameters\n", + "scaffold_best_idx = df_scaffold_cv_results[['mean_test_score']].idxmax().values[0]\n", + "random_best_idx = df_random_cv_results[['mean_test_score']].idxmax().values[0]\n", + "\n", + "#get the CV scores of the best hyperparameters\n", + "scaffold_split_cv_score = df_scaffold_cv_results.loc[scaffold_best_idx,df_scaffold_cv_results.columns.str.contains(\"split\")]\n", + "random_split_cv_score = df_random_cv_results.loc[random_best_idx,df_random_cv_results.columns.str.contains(\"split\")]\n", + "\n", + "#prepare dataframe for boxplot\n", + "df_cv_compare[\"score\"] = list(scaffold_split_cv_score) + list(random_split_cv_score)\n", + "df_cv_compare[\"split\"] = [\"scaffold\" for i in scaffold_split_cv_score ] + [\"random\" for i in random_split_cv_score ]\n", + "\n", + "sns.boxplot(data=df_cv_compare, x=\"split\", y=\"score\",hue=\"split\")\n", + "plt.ylabel(\"$R^2$ validation score\",fontsize=13)\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": 50, + "metadata": {}, + "outputs": [], + "source": [ + "#!pip install statsmodels\n", + "from statsmodels.stats.multicomp import pairwise_tukeyhsd\n", + "thsd = pairwise_tukeyhsd(df_cv_compare.score, df_cv_compare.split)" + ] + }, + { + "cell_type": "code", + "execution_count": 51, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " Multiple Comparison of Means - Tukey HSD, FWER=0.05 \n", + "=====================================================\n", + "group1 group2 meandiff p-adj lower upper reject\n", + "-----------------------------------------------------\n", + "random scaffold -0.3827 0.0 -0.4249 -0.3404 True\n", + "-----------------------------------------------------\n" + ] + } + ], + "source": [ + "print(thsd)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In TukeyHSD test, if **p-adj < 0.05**, there is a **statistically significant difference**. \n", + "\n", + "In real world scenario, the new compounds to be evaluated will likely contain different scaffolds from that of the training set.\n", + "\n", + "Now, let's compare the model performance on unseen scaffolds, i.e. our **test set**." + ] + }, + { + "cell_type": "code", + "execution_count": 52, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "y_pred_random = search_random.best_estimator_.predict(x_test)\n", + "r2_random = r2_score(y_test,y_pred_random)\n", + "\n", + "sns.scatterplot(x=y_test, y=y_pred_scaffold,color='b',label=f\"Scaffold split, Test $R^2$={r2_scaffold:.2f}\")\n", + "sns.scatterplot(x=y_test, y=y_pred_random,color='r',label=f\"Random split, Test $R^2$={r2_random:.2f}\")\n", + "plt.plot(y_test,y_test,color='g')\n", + "\n", + "plt.xlabel(\"Experimental\")\n", + "plt.ylabel(\"Predicted\")\n", + "plt.legend(fontsize=12)\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": 53, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
split typevalidation scoretest score
0scaffold0.2932150.43378
1random0.6608200.43378
\n", + "
" + ], + "text/plain": [ + " split type validation score test score\n", + "0 scaffold 0.293215 0.43378\n", + "1 random 0.660820 0.43378" + ] + }, + "execution_count": 53, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df_cv_test = pd.DataFrame()\n", + "df_cv_test[\"split type\"] = [\"scaffold\",\"random\"] \n", + "df_cv_test[\"validation score\"] = [scaffold_split_cv_score.median(),random_split_cv_score.median()] \n", + "df_cv_test[\"test score\"] = [r2_scaffold,r2_random]\n", + "df_cv_test" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Take aways\n", + "\n", + "In this example, the model trained via scaffold splits tends to have better generalizability on unseen scaffolds.\n", + "\n", + "Moreover, the discrepancy between the validation and test score is higher in random splits than in scaffold splits.\n", + "\n", + "Based on this result, scaffold splits can potentially:\n", + "- give **a more realistic estimation of model performance than the default random splits**\n", + "- **help mitigate overfitting** during hyperparameter optimization\n", + "\n", + "Although this outcome could highly depend on the data itself (as well as the arbitrary random states), this notebook shows the advantage of using scaffold splits, which has now a **convenient implementation in scikit-mol**. :)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "scikit_mol", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.4" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/scikit_mol/splitter.py b/scikit_mol/splitter.py new file mode 100644 index 0000000..8f5e10b --- /dev/null +++ b/scikit_mol/splitter.py @@ -0,0 +1,437 @@ +import numpy as np +import matplotlib.pyplot as plt +import time +import warnings +import pandas as pd +from collections import defaultdict +from typing import Union, List +from sklearn.model_selection._split import BaseShuffleSplit, _validate_shuffle_split +from sklearn.utils.validation import _num_samples +from sklearn.utils import check_random_state +from sklearn.model_selection import GroupShuffleSplit +from sklearn.utils import indexable +from sklearn.utils._array_api import ensure_common_namespace_device +from itertools import chain +from sklearn.utils import _safe_indexing + + +class StratifiedGroupShuffleSplit(BaseShuffleSplit): + """Stratified ShuffleSplit cross-validator with non-overlapping groups.""" + + def __init__( + self, + n_splits: int = 5, + *, + test_size: float or int = 0.2, + train_size: float or int=None, + random_state: int = None, + sample_weighted: bool = False, + suppress_warnings: bool = False + ): + super().__init__( + n_splits=n_splits, + test_size=test_size, + train_size=train_size, + random_state=random_state, + ) + self.sample_weighted = sample_weighted + self.suppress_warnings = suppress_warnings + + if not suppress_warnings: + if self.sample_weighted: + warnings.warn( + f"sample_weighted = True. During the test split, groups with more samples will be prioritized", + UserWarning, + ) + + def _iter_indices(self, X: Union[List, np.ndarray, pd.Series], y: Union[List, np.ndarray, pd.Series], groups: Union[List, np.ndarray, pd.Series]): + + if y is None: + raise ValueError( + "StratifiedGroupShuffleSplit requires 'y' for stratification." + ) + + if groups is None: + raise ValueError( + "StratifiedGroupShuffleSplit requires 'groups' to be defined." + ) + n_samples = _num_samples(X) + + if isinstance(self.test_size, float): + n_test = int(self.test_size * n_samples) + else: + n_test = int(self.test_size) + + unique_groups, group_indices = np.unique(groups, return_inverse=True) + group_counts = np.bincount(groups) + self._check_split_viability(n_test, unique_groups, group_counts) + n_groups = len(unique_groups) + classes, y_indices = np.unique(y, return_inverse=True) + n_classes = len(classes) + overall_class_counts = np.bincount(y_indices, minlength=n_classes) + + group_info = defaultdict( + lambda: { + "class_counts": np.zeros(n_classes, dtype=int), + "indices": [], + "size": 0, + } + ) + for i, group_idx in enumerate(group_indices): + class_idx = y_indices[i] + group_info[group_idx]["class_counts"][class_idx] += 1 + group_info[group_idx]["indices"].append(i) + for i in range(n_groups): + group_info[i]["size"] = len(group_info[i]["indices"]) + + rng = check_random_state(self.random_state) + + for _ in range(self.n_splits): + available_groups = list(range(n_groups)) + test_groups = [] + + current_test_size = 0 + current_test_counts = np.zeros(n_classes, dtype=int) + + # Phase 1: Greedily add only "safe" groups that do not exceed n_test + while available_groups: + safe_candidates = [] + for group_idx in available_groups: + group_data = group_info[group_idx] + if current_test_size + group_data["size"] <= n_test: + prospective_counts = ( + current_test_counts + group_data["class_counts"] + ) + prospective_size = current_test_size + group_data["size"] + ideal_counts = overall_class_counts * ( + prospective_size / n_samples + ) + error = np.sum((prospective_counts - ideal_counts) ** 2) + safe_candidates.append({"error": error, "id": group_idx}) + + if not safe_candidates: + # No more groups can be added without overshooting + break + + safe_candidates.sort(key=lambda x: x["error"]) + pool_size = min(5, len(safe_candidates)) + candidate_pool = [cand["id"] for cand in safe_candidates[:pool_size]] + if self.sample_weighted: + weights = [group_info[group_idx]["size"] for group_idx in candidate_pool] + best_group = rng.choice(candidate_pool, p=weights) + else: + best_group = rng.choice(candidate_pool) + + test_groups.append(best_group) + available_groups.remove(best_group) + group_data = group_info[best_group] + current_test_counts += group_data["class_counts"] + current_test_size += group_data["size"] + + # Phase 2: Decide if a single overshoot is better than the current undershoot + if available_groups and current_test_size < n_test: + overshoot_candidates = [] + for group_idx in available_groups: + group_data = group_info[group_idx] + prospective_size = current_test_size + group_data["size"] + # We only care about the size difference now + overshoot_candidates.append( + {"id": group_idx, "size": prospective_size} + ) + + if overshoot_candidates: + # Find the group that causes the smallest overshoot + overshoot_candidates.sort(key=lambda x: x["size"]) + best_overshoot_group = overshoot_candidates[0] + + undershoot_error = n_test - current_test_size + overshoot_error = best_overshoot_group["size"] - n_test + + valid_overshoot_candidates = [] + for cand in overshoot_candidates: + overshoot_error = cand["size"] - n_test + if overshoot_error < undershoot_error: + valid_overshoot_candidates.append(cand["id"]) + + if valid_overshoot_candidates: + # Randomly choose from the valid overshooting groups + best_overshoot_group_id = rng.choice(valid_overshoot_candidates) + test_groups.append(best_overshoot_group_id) + + test_indices = ( + np.concatenate([group_info[g_idx]["indices"] for g_idx in test_groups]) + if test_groups + else [] + ) + if len(test_indices) == 0: + raise RuntimeError(f"Given the dataset, no train/test split could be found. Try increasing test_size") + all_indices = np.arange(n_samples) + train_indices = np.setdiff1d(all_indices, test_indices, assume_unique=True) + + if isinstance(self.test_size, float): + + requested_test_size_ratio = self.test_size + else: + requested_test_size_ratio = self.test_size / n_samples + + test_size_error = np.abs(len(test_indices)/n_samples - requested_test_size_ratio) + + if not self.suppress_warnings: + if test_size_error > 0.05: # 5% deviation + warnings.warn(f"Requested and calculated test sizes differ by {test_size_error*100:.2f}%") + + yield train_indices, test_indices + + def split(self, X, y, groups=None): + """Generates indices to split data into training and test set. + + Parameters + ---------- + X : array-like of shape (n_samples, n_features) + Training data, where `n_samples` is the number of samples + and `n_features` is the number of features. + + y : array-like of shape (n_samples,), optional + The target variable for supervised learning problems. + Stratification is done based on the y labels. + + groups : array-like of shape (n_samples,), optional + Group labels for the samples used while splitting the dataset into + train/test set. Each group will be kept together in either the + train set or the test set. + + Yields + ------ + train : ndarray + The training set indices for that split. + + test : ndarray + The testing set indices for that split. + """ + yield from self._iter_indices(X, y, groups) + + def get_n_splits(self): + return self.n_splits + + def _check_split_viability(self, n_test, unique_groups, group_counts): + too_large_groups = {} + n_groups = 0 + for group_id, group_count in zip(unique_groups, group_counts): + if group_count >= n_test: + n_groups += 1 + too_large_groups[group_id] = group_count + if len(too_large_groups) > 0 and not self.suppress_warnings and n_groups < len(unique_groups): + warnings.warn( + f''' + Some groups are too large for the test set and will never be present in the test set: {too_large_groups}.\n + If you want a group to be able to be present in the test set, test_size >= group_size. + ''', + UserWarning, + ) + elif len(too_large_groups) > 0 and not self.suppress_warnings and n_groups == len(unique_groups): + warnings.warn( + ''' + "Warning: All available groups are larger than the target test size. + The algorithm will still try to select a group that overshoots the target, + which may lead to a larger than requested test set, or an completely empty test set." + ''', + UserWarning, + ) + + +def train_test_group_split( + *arrays, + test_size=None, + train_size=None, + random_state=None, + shuffle=True, + stratify=None, +): + """Split arrays or matrices into random train and test subsets, while respecting group boundaries. + + Quick utility that wraps input validation and a Group-aware ShuffleSplit + into a single call for splitting (and optionally subsampling) data in a + one-liner. + + The last passed array is assumed to be the 'groups' array. + + Read more in the :ref:`User Guide `. + + Parameters + ---------- + *arrays : sequence of indexables with same length / shape[0] + Allowed inputs are lists, numpy arrays, scipy-sparse + matrices or pandas dataframes. The last array must be the `groups` + array. + + test_size : float or int, default=None + If float, should be between 0.0 and 1.0 and represent the proportion + of the dataset to include in the test split. If int, represents the + absolute number of test samples. If None, the value is set to the + complement of the train size. If ``train_size`` is also None, it will + be set to 0.25. + + train_size : float or int, default=None + If float, should be between 0.0 and 1.0 and represent the + proportion of the dataset to include in the train split. If + int, represents the absolute number of train samples. If None, + the value is automatically set to the complement of the test size. + + random_state : int, RandomState instance or None, default=None + Controls the shuffling applied to the data before applying the split. + Pass an int for reproducible output across multiple function calls. + See :term:`Glossary `. + + shuffle : bool, default=True + Whether or not to shuffle the data before splitting. For group-based + splitting, shuffling is always performed on the groups. If shuffle=False, + a ValueError will be raised. + + stratify : array-like or bool, default=None + If not None, data is split in a stratified fashion, using this as + the class labels. If True, it will use the second to last array as + stratification labels. + Read more in the :ref:`User Guide `. + + Returns + ------- + splitting : list, length=2 * len(arrays) + List containing train-test split of inputs. + """ + n_arrays = len(arrays) + if n_arrays < 2: + raise ValueError( + "At least two arrays are required as input (e.g., X, groups)." + ) + + arrays = indexable(*arrays) + groups = arrays[-1] + + n_samples = _num_samples(arrays[0]) + n_train, n_test = _validate_shuffle_split( + n_samples, test_size, train_size, default_test_size=0.25 + ) + + if not shuffle: + raise ValueError( + "shuffle=False is not supported for train_test_group_split. " + "Group-based splitting always shuffles the groups." + ) + + y_for_split = None + if stratify is not None: + if isinstance(stratify, bool): + if stratify: # stratify=True + if n_arrays < 3: + raise ValueError( + "When stratify=True, at least three arrays are required as input (e.g., X, y, groups)." + ) + y_for_split = arrays[-2] + CVClass = StratifiedGroupShuffleSplit + else: # stratify=False + CVClass = GroupShuffleSplit + else: # stratify is an array + y_for_split = stratify + CVClass = StratifiedGroupShuffleSplit + else: # stratify is None + CVClass = GroupShuffleSplit + + cv = CVClass(n_splits=1, test_size=n_test, train_size=n_train, random_state=random_state) + + train, test = next(cv.split(X=arrays[0], y=y_for_split, groups=groups)) + + train, test = ensure_common_namespace_device(arrays[0], train, test) + + return list( + chain.from_iterable( + (_safe_indexing(a, train), _safe_indexing(a, test)) for a in arrays + ) + ) + + +class GroupSplitCV: + """Cross-validator that performs group-aware splits. + + This cross-validator is a wrapper around StratifiedGroupShuffleSplit and + GroupShuffleSplit to be used in scikit-learn's GridSearchCV and other + similar utilities. + + Parameters + ---------- + n_splits : int, default=5 + Number of re-shuffling & splitting iterations. + + test_size : float or int, default=0.2 + If float, should be between 0.0 and 1.0 and represent the proportion + of the dataset to include in the test split. If int, represents the + absolute number of test samples. + + train_size : float or int, default=None + If float, should be between 0.0 and 1.0 and represent the + proportion of the dataset to include in the train split. If + int, represents the absolute number of train samples. If None, + the value is automatically set to the complement of the test size. + + random_state : int, RandomState instance or None, default=None + Controls the randomness of the training and testing indices produced. + Pass an int for reproducible output across multiple function calls. + + stratify : bool, default=False + Whether to perform stratified sampling. If True, the `y` parameter + in the `split` method is used for stratification. + """ + def __init__(self, n_splits=5, *, test_size=0.2, train_size=None, random_state=None, stratify=False): + self.n_splits = n_splits + self.test_size = test_size + self.train_size = train_size + self.random_state = random_state + self.stratify = stratify + + def split(self, X, y=None, groups=None): + """ + Generate indices to split data into training and test set. + + Parameters + ---------- + X : array-like of shape (n_samples, n_features) + Training data, where n_samples is the number of samples + and n_features is the number of features. + + y : array-like of shape (n_samples,), default=None + The target variable for supervised learning problems. + Stratification is done based on the y labels if `stratify=True`. + + groups : array-like of shape (n_samples,) + Group labels for the samples used while splitting the dataset into + train/test set. + + Yields + ------ + train : ndarray + The training set indices for that split. + + test : ndarray + The testing set indices for that split. + """ + if self.stratify: + if y is None: + raise ValueError("The 'y' parameter should not be None when stratify=True.") + cv = StratifiedGroupShuffleSplit( + n_splits=self.n_splits, + test_size=self.test_size, + train_size=self.train_size, + random_state=self.random_state, + ) + yield from cv.split(X, y, groups=groups) + else: + cv = GroupShuffleSplit( + n_splits=self.n_splits, + test_size=self.test_size, + train_size=self.train_size, + random_state=self.random_state, + ) + yield from cv.split(X, y=y, groups=groups) + + def get_n_splits(self, X=None, y=None, groups=None): + """Returns the number of splitting iterations in the cross-validator.""" + return self.n_splits \ No newline at end of file diff --git a/tests/test_conversions.py b/tests/test_conversions.py new file mode 100644 index 0000000..8bacedc --- /dev/null +++ b/tests/test_conversions.py @@ -0,0 +1,181 @@ +""" +Tests for the scaffold generator classes in scikit_mol.conversions. +""" + +import pytest +from rdkit import Chem +from sklearn.base import clone +from sklearn.pipeline import Pipeline + +from scikit_mol.conversions import ( + MurckoScaffoldGenerator, + MolToScaffoldTransformer, + SmilesToMolTransformer, +) + +# A selection of SMILES strings for testing +MURCKO_TEST_CASES = { + # Aspirin: simple case with a benzene ring and two side chains + "CC(=O)OC1=CC=CC=C1C(=O)O": "c1ccccc1", + # Ibuprofen: another common drug example + "CC(C)CC1=CC=C(C=C1)C(C)C(=O)O": "c1ccccc1", + # Atorvastatin (Lipitor): complex molecule with multiple rings. + # The expected scaffold is the result from RDKit's MurckoScaffold. + "CC(C)C1=C(C(=C(N1C2=CC=C(C=C2)F)C3=CC=CC=C3)C(=O)NC4=CC=C(C=C4)F)C(O)CC(O)CC(=O)O": "O=C(Nc1ccccc1)c1ccn(-c2ccccc2)c1-c1ccccc1", + # A molecule with no rings, should result in an empty scaffold + "CCO": "", + # A molecule with a chiral center but no rings, should also be empty + "C[C@H](O)C(=O)O": "", # Lactic acid +} + + +@pytest.fixture +def murcko_generator(): + """Provides a default MurckoScaffoldGenerator instance.""" + return MurckoScaffoldGenerator() + + +def test_murcko_generator_initialization(): + """ + Tests that the MurckoScaffoldGenerator initializes correctly and that + its parameters are stored as public attributes, which is a requirement + for scikit-learn compatibility. + """ + generator = MurckoScaffoldGenerator(include_chirality=True, make_generic=True) + assert generator.include_chirality + assert generator.make_generic + + +def test_murcko_scaffold_generation(murcko_generator): + """ + Tests the basic scaffold generation for a set of common molecules. + This test ensures that the core functionality of converting a molecule + to its Murcko scaffold is working as expected. + """ + for smiles, expected_scaffold_smiles in MURCKO_TEST_CASES.items(): + mol = Chem.MolFromSmiles(smiles) + scaffold = murcko_generator.get_scaffold(mol) + + # For molecules without rings, the scaffold should be '' + if not expected_scaffold_smiles: + assert scaffold is None, f"Scaffold should be empty for {smiles}" + continue + + assert scaffold is not None, f"Scaffold generation failed for {smiles}" + # Compare the SMILES representation of the generated scaffold to the expected one + scaffold_smiles = Chem.MolToSmiles(scaffold) + assert scaffold_smiles == expected_scaffold_smiles, f"Incorrect scaffold for {smiles}" + + +def test_murcko_chirality_option(): + """ + Tests the `include_chirality` parameter of the MurckoScaffoldGenerator. + When this option is enabled, the scaffold should preserve the stereochemistry + of the original molecule. This test uses a molecule with a chiral ring, + as Murcko scaffolds are only generated for cyclic systems. + """ + # A chiral molecule with a ring system + chiral_smiles = "C1CC[C@H]2CCCC[C@H]2C1" + mol = Chem.MolFromSmiles(chiral_smiles) + assert mol is not None, "Failed to create molecule from SMILES" + + # Test with chirality disabled (default) + generator_no_chirality = MurckoScaffoldGenerator(include_chirality=False) + scaffold_no_chirality = generator_no_chirality.get_scaffold(mol) + assert scaffold_no_chirality is not None, "Scaffold should not be None" + assert "@" not in Chem.MolToSmiles( + scaffold_no_chirality + ), "Scaffold should not have chiral centers" + + # Test with chirality enabled + generator_with_chirality = MurckoScaffoldGenerator(include_chirality=True) + scaffold_with_chirality = generator_with_chirality.get_scaffold(mol) + assert scaffold_with_chirality is not None, "Chiral scaffold should not be None" + assert "@" in Chem.MolToSmiles( + scaffold_with_chirality + ), "Scaffold should have chiral centers" + + +def test_murcko_generic_option(): + """ + Tests the `make_generic` parameter of the MurckoScaffoldGenerator. + When this option is enabled, all atoms in the scaffold are converted to + carbon and all bonds to single bonds, providing a generic framework. + """ + smiles = "CC1=CC=C(C=C1)C(C)C(=O)O" # Ibuprofen + mol = Chem.MolFromSmiles(smiles) + generator = MurckoScaffoldGenerator(make_generic=True) + scaffold = generator.get_scaffold(mol) + + assert scaffold is not None, "Scaffold generation failed" + # Check that all atoms in the generic scaffold are carbons (atomic number 6) + for atom in scaffold.GetAtoms(): + assert atom.GetAtomicNum() == 6, "All atoms should be carbon in a generic scaffold" + # Check that all bonds are single bonds + for bond in scaffold.GetBonds(): + assert ( + bond.GetBondType() == Chem.rdchem.BondType.SINGLE + ), "All bonds should be single in a generic scaffold" + + +def test_mol_to_scaffold_transformer_integration(): + """ + Tests the integration of MurckoScaffoldGenerator with MolToScaffoldTransformer + within a scikit-learn Pipeline. This ensures the transformers correctly chain + together, with the second transformer operating on the output of the first. + """ + smiles_list = list(MURCKO_TEST_CASES.keys()) + + # Define the pipeline + pipeline = Pipeline([ + ('smiles_to_mol', SmilesToMolTransformer()), + ('mol_to_scaffold', MolToScaffoldTransformer( + scaffold_generator=MurckoScaffoldGenerator() + )) + ]) + + # Transform the data through the pipeline + scaffolds = pipeline.transform(smiles_list) + + assert len(scaffolds) == len( + smiles_list + ), "Pipeline should output one scaffold per input SMILES" + + # Check the output for a known case (Aspirin) + aspirin_scaffold = scaffolds[0][0] + assert isinstance(aspirin_scaffold, Chem.Mol), "Output should be an RDKit Mol object" + assert Chem.MolToSmiles(aspirin_scaffold) == "c1ccccc1", "Incorrect scaffold for Aspirin" + + # Check the output for an acyclic case (Ethanol) + # The scaffold should be an InvalidMol object as per the transformer's logic + from scikit_mol.core import InvalidMol + ethanol_scaffold = scaffolds[3][0] + assert isinstance(ethanol_scaffold, InvalidMol), "Acyclic molecules should produce an InvalidMol object" + + +def test_scikit_learn_compatibility(): + """ + Verifies that the MurckoScaffoldGenerator is compatible with scikit-learn's + `clone` function and can be used in a Pipeline. This is essential for + hyperparameter tuning and other advanced scikit-learn workflows. + """ + generator = MurckoScaffoldGenerator(include_chirality=True) + # Test that the generator can be cloned + cloned_generator = clone(generator) + assert cloned_generator.include_chirality == generator.include_chirality + assert cloned_generator is not generator, "Cloned object should be a new instance" + + # Test that the generator can be used in a scikit-learn Pipeline + pipeline = Pipeline( + [ + ("smiles_to_mol", SmilesToMolTransformer()), + ( + "mol_to_scaffold", + MolToScaffoldTransformer(scaffold_generator=generator), + ), + ] + ) + # Cloning the pipeline should also work seamlessly + cloned_pipeline = clone(pipeline) + assert cloned_pipeline.steps[1][1].scaffold_generator.include_chirality +