diff --git a/examples/notebooks/05_advanced/05_custom_model_tuner.ipynb b/examples/notebooks/05_advanced/05_custom_model_tuner.ipynb new file mode 100644 index 0000000..28660db --- /dev/null +++ b/examples/notebooks/05_advanced/05_custom_model_tuner.ipynb @@ -0,0 +1,586 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "e8fa1e70", + "metadata": {}, + "source": [ + "# Customize Tuner for Neural Network Optimization\n", + "\n", + "In this notebook, we explore how to customize hyperparameter optimization for a model:\n", + "- Use different hyperparameter selections or settings for the standard models\n", + "- Optimize only part of a pipeline (e.g. tune only the classifier while keeping the embedder fixed) using `PipelineWithHyperparameterRooting` and a custom `AbstractMotherTuner`\n", + "- Define a custom model and use it in hyperparameter optimization\n", + "\n", + "## Key concepts from `mother.optimization.core`\n", + "\n", + "### `AbstractMotherTuner`\n", + "An abstract base class for building custom tuners. Subclasses must implement two methods:\n", + "- **`objective(trial, context)`** — defines a single Optuna trial: samples hyperparameters, trains and evaluates the model, and returns a scalar score.\n", + "- **`call_optimize(context)`** — controls the overall optimization loop, i.e. how `study.optimize()` is called with `self.objective`.\n", + "\n", + "The `optimize()` method (inherited from `AbstractMotherTuner`) orchestrates the full workflow: it creates the Optuna study, assembles the `ObjectiveContext`, enqueues any default parameters, and finally retrains the best model on the full dataset.\n", + "\n", + "### `ObjectiveContext`\n", + "A dataclass that bundles all arguments from `optimize()` into a single object passed to `objective()` and `call_optimize()`. Its key fields are:\n", + "\n", + "| Field | Description |\n", + "|---|---|\n", + "| `get_hyper_space` | Callable that, given a trial, returns a hyperparameter dict |\n", + "| `estimator` | The unfitted pipeline to be tuned |\n", + "| `X` / `y` | Training data and targets |\n", + "| `cross_validation` | Cross-validator (e.g. `KFold`) |\n", + "| `groups` | Optional group labels for grouped CV |\n", + "| `fit_kwargs` | Extra keyword arguments forwarded to `estimator.fit()` |\n", + "| `extras` | Arbitrary additional data your custom tuner may need |" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "594c58ba", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/workspaces/MotherML/.venv/lib/python3.13/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", + " from .autonotebook import tqdm as notebook_tqdm\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Train / Validation samples = 455 / 114 with 30 features and 2 labels.\n" + ] + } + ], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2\n", + "from sklearn.datasets import load_breast_cancer\n", + "from sklearn.model_selection import train_test_split\n", + "\n", + "from torch import nn\n", + "from six import iteritems\n", + "\n", + "from optuna import Trial\n", + "import sklearn.base as skl_base\n", + "from sklearn.model_selection import KFold\n", + "from sklearn.metrics import accuracy_score\n", + "\n", + "\n", + "import mother.optimization as opt\n", + "\n", + "X, y = load_breast_cancer(return_X_y=True, as_frame=True)\n", + "X_train, X_valid, y_train, y_valid = train_test_split(X, y, test_size=0.2)\n", + "print(\n", + " f\"Train / Validation samples = {X_train.shape[0]} / {X_valid.shape[0]} with {X_train.shape[1]} features and {y_train.nunique()} labels.\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "35fb9c8f", + "metadata": {}, + "source": [ + "## 1. Optimize Part of a Pipeline\n", + "\n", + "With a custom optimizer, you can optimize only a part of your pipeline.\n", + "\n", + "Here, we optimize the classification model in a pipeline consisting of `TabPFNEmbeddingTransformer` (to generate model-based embeddings from the input features) and `CatboostClassifier`.\n", + "\n", + "### 1.1 Create a Pipeline" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "b7ec9436", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[('embedder', TabPFNEmbeddingTransformer(use_kfold=False)),\n", + " ('classifier',\n", + " )]" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from mother.ml.models.m_tabpfn import TabPFNEmbeddingTransformer\n", + "from mother.ml.models.m_catboost import CatboostClassifierMother\n", + "from mother.ml import PipelineWithHyperparameterRooting\n", + "\n", + "model = PipelineWithHyperparameterRooting(\n", + " [\n", + " (\n", + " \"embedder\",\n", + " TabPFNEmbeddingTransformer(\n", + " model_type=\"classification\",\n", + " use_kfold=False, # these parameters will not change after optimization\n", + " ),\n", + " ),\n", + " (\"classifier\", CatboostClassifierMother(target_type=\"single_target\", logging_level=\"Silent\")),\n", + " ]\n", + ")\n", + "\n", + "model.steps" + ] + }, + { + "cell_type": "markdown", + "id": "c8a752ee", + "metadata": {}, + "source": [ + "In an sklearn pipeline, parameter names indicate which step they belong to using the convention `{step_name}__{parameter_name}`." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "55919c3e", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'memory': None,\n", + " 'steps': [('embedder', TabPFNEmbeddingTransformer(use_kfold=False)),\n", + " ('classifier',\n", + " )],\n", + " 'verbose': False,\n", + " 'embedder': TabPFNEmbeddingTransformer(use_kfold=False),\n", + " 'classifier': ,\n", + " 'embedder__device': 'cpu',\n", + " 'embedder__embedding_column_name': 'tabpfnembedding',\n", + " 'embedder__ignore_pretraining_limits': True,\n", + " 'embedder__model': None,\n", + " 'embedder__model_type': 'classification',\n", + " 'embedder__n_folds': 5,\n", + " 'embedder__random_state': None,\n", + " 'embedder__return_separate_columns': True,\n", + " 'embedder__use_kfold': False,\n", + " 'classifier__learning_rate': 0.03,\n", + " 'classifier__loss_function': 'Logloss',\n", + " 'classifier__logging_level': 'Silent',\n", + " 'classifier__auto_class_weights': 'Balanced',\n", + " 'classifier__random_strength': 1,\n", + " 'classifier__boosting_type': 'Plain',\n", + " 'classifier__bootstrap_type': 'Bayesian',\n", + " 'classifier__max_depth': 6,\n", + " 'classifier__grow_policy': 'SymmetricTree',\n", + " 'classifier__posterior_sampling': True,\n", + " 'classifier__target_type': 'single_target',\n", + " 'classifier__tune_boosting_type': False,\n", + " 'classifier__model_type': 'classification_binary',\n", + " 'classifier__tune_tree_structure_type': True}" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "model.get_params()" + ] + }, + { + "cell_type": "markdown", + "id": "40564642", + "metadata": {}, + "source": [ + "### 1.2 Custom Optimizer\n", + "\n", + "In the custom `objective` function, we select only parameters whose names start with `classifier`, so that only the classifier is optimized while the embedder remains fixed." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "9a84e18e", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/workspaces/MotherML/src/mother/optimization/core.py:135: ExperimentalWarning: Argument ``multivariate`` is an experimental feature. The interface can change in the future.\n", + " self.sampler = optuna.samplers.TPESampler(\n", + "/workspaces/MotherML/src/mother/optimization/core.py:135: ExperimentalWarning: Argument ``group`` is an experimental feature. The interface can change in the future.\n", + " self.sampler = optuna.samplers.TPESampler(\n", + "/workspaces/MotherML/src/mother/optimization/core.py:135: ExperimentalWarning: Argument ``constant_liar`` is an experimental feature. The interface can change in the future.\n", + " self.sampler = optuna.samplers.TPESampler(\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Trial 0 parameters to tune : {'classifier__bootstrap_type': 'Bayesian', 'classifier__learning_rate': 0.03, 'classifier__random_strength': 1, 'classifier__grow_policy': 'SymmetricTree', 'classifier__max_depth': 6, 'classifier__loss_function': 'Logloss'}\n", + "Trial 1 parameters to tune : {'classifier__bootstrap_type': 'Bayesian', 'classifier__learning_rate': 0.02208007163250455, 'classifier__random_strength': 1.1961172071448225, 'classifier__grow_policy': 'SymmetricTree', 'classifier__max_depth': 5, 'classifier__loss_function': 'Logloss'}\n", + "Trial 2 parameters to tune : {'classifier__bootstrap_type': 'Bayesian', 'classifier__learning_rate': 0.18132275158254602, 'classifier__random_strength': 0.8829987555087553, 'classifier__grow_policy': 'SymmetricTree', 'classifier__max_depth': 6, 'classifier__auto_class_weights': 'None', 'classifier__loss_function': 'Focal:focal_alpha=0.3924531071968482;focal_gamma=6.2963722761684995'}\n", + "{'classifier__bootstrap_type': 'Bayesian', 'classifier__learning_rate': 0.03, 'classifier__random_strength': 1.0, 'classifier__grow_policy': 'SymmetricTree', 'classifier__max_depth': 6, 'classifier__loss_function': 'Logloss'}\n" + ] + } + ], + "source": [ + "class CustomMotherTuner(opt.AbstractMotherTuner):\n", + " def __init__(self, **kwargs):\n", + " # create a customised scorer\n", + " super().__init__(**kwargs)\n", + "\n", + " def objective(self, trial: Trial, context: opt.ObjectiveContext) -> float:\n", + " cv_score = 0.0\n", + "\n", + " suggested_params_to_train: dict = context.get_hyper_space(trial=trial, X=context.X, y=context.y)\n", + "\n", + " # select params only for \"classifier\"\n", + " suggested_params_to_train = {k: v for k, v in suggested_params_to_train.items() if k.startswith(\"classifier\")}\n", + "\n", + " print(f\"Trial {trial.number} parameters to tune : {suggested_params_to_train}\")\n", + "\n", + " for train_idx, test_idx in context.cross_validation.split(context.X, context.y):\n", + " # Train\n", + " pipeline = skl_base.clone(context.estimator)\n", + "\n", + " # fit\n", + " pipeline.set_params(**suggested_params_to_train)\n", + " pipeline.fit(context.X.iloc[train_idx, :], context.y.iloc[train_idx])\n", + "\n", + " # Test score\n", + " y_pred_test = pipeline.predict(X=context.X.iloc[test_idx, :])\n", + " cv_score += accuracy_score(context.y.iloc[test_idx], y_pred_test)\n", + "\n", + " return cv_score / (context.cross_validation.get_n_splits()) # mean acc\n", + "\n", + " def call_optimize(self, context: opt.ObjectiveContext) -> None:\n", + " self.study.optimize(\n", + " lambda trial: self.objective(trial, context=context),\n", + " n_trials=self.n_trials_optuna,\n", + " gc_after_trial=True,\n", + " callbacks=self.get_callbacks(),\n", + " )\n", + "\n", + "\n", + "tuner = CustomMotherTuner(\n", + " n_trials_optuna=3, # number of trials for hyperparameter optimization\n", + " n_threads_optuna=10, # parallel threads for cross-validation evaluation\n", + " n_startup_trials=1, # number of random trials before using optuna\n", + " tuning_direction=\"maximize\", # Maximize the accuracy\n", + ")\n", + "\n", + "model_tuned = tuner.optimize(\n", + " model,\n", + " X_train.iloc[:100, :],\n", + " y_train.iloc[:100],\n", + " cross_validation=KFold(n_splits=2),\n", + " hyperparameter_space_function=model.get_hyperparameter_space,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "45ee6a5e", + "metadata": {}, + "source": [ + "After optimization, we can verify that the embedder parameters remain unchanged." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "a644880d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "False" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "model_tuned.get_params()[\"embedder__use_kfold\"]" + ] + }, + { + "cell_type": "markdown", + "id": "a912f3b4", + "metadata": {}, + "source": [ + "## 2. Mother Optimization for a PyTorch Model\n", + "\n", + "In this example, we create a custom `torch` model wrapper using `AbstractMotherPipeline` and a custom optimizer for the model.\n", + "\n", + "### 2.1 Torch Wrapper for Mother\n", + "In this example, we only tune the learning rate, but additional parameters can be added for optimization." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6bcd9573", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "(0.2540920674800873, 0.9035087719298246)\n" + ] + } + ], + "source": [ + "from collections import OrderedDict\n", + "from mother.ml import AbstractMotherPipeline\n", + "from torch.optim import Adam\n", + "from torch.utils.data import TensorDataset, DataLoader\n", + "import torch\n", + "from mother.ml.models.utils import add_prefix_to_dict_keys\n", + "from optuna.trial import Trial\n", + "import pandas as pd\n", + "from sklearn.metrics import accuracy_score\n", + "\n", + "\n", + "class TorchNNMother(AbstractMotherPipeline):\n", + " def __init__(self, lr: float = 1e-3, **kwargs) -> None:\n", + "\n", + " self.network = nn.Sequential(\n", + " OrderedDict(\n", + " [\n", + " (\"linear1\", nn.Linear(in_features=30, out_features=10)),\n", + " (\"relu\", nn.ReLU()),\n", + " (\"linear2\", nn.Linear(in_features=10, out_features=1)),\n", + " (\"sigmoid\", nn.Sigmoid()),\n", + " ]\n", + " )\n", + " )\n", + "\n", + " self.optimizer = Adam(self.network.parameters(), lr=lr)\n", + " self.lr = lr\n", + " self.loss = nn.BCELoss()\n", + " self._init_params: dict = {\"lr\": lr}\n", + "\n", + " non_optimised_params: list[str] = [\"_init_params\"]\n", + " for k, v in kwargs.items():\n", + " if k not in non_optimised_params:\n", + " self._init_params[k] = v\n", + "\n", + " def default_parameters(self, prefix: str = \"\") -> dict:\n", + " return add_prefix_to_dict_keys({\"lr\": 1e-3}, prefix=prefix)\n", + "\n", + " def get_hyperparameter_space(self, X, y, trial: Trial, prefix: str = \"\") -> dict:\n", + " # hyper parameter search for learning rate\n", + " suggested_params: dict = {\"lr\": trial.suggest_float(prefix + \"lr\", 1e-5, 1e-3, log=True)}\n", + " suggested_params = add_prefix_to_dict_keys(suggested_params, prefix=prefix)\n", + "\n", + " return suggested_params\n", + "\n", + " def get_params(self, deep=True) -> dict:\n", + " return self._init_params\n", + "\n", + " def set_params(self, **params):\n", + " for key, value in iteritems(params):\n", + " if key in self._init_params.keys():\n", + " self._init_params[key] = value\n", + "\n", + " # Keep runtime attributes and optimizer in sync with tuned params.\n", + " self.lr = float(self._init_params[\"lr\"])\n", + "\n", + " return self.__init__(**self._init_params)\n", + "\n", + " def _get_dataloader(self, X, y, batch_size: int = 128, shuffle: bool = False) -> DataLoader:\n", + " # Crate a dataloader for torch training\n", + " if isinstance(X, pd.DataFrame):\n", + " X = X.values\n", + " if isinstance(y, pd.Series):\n", + " y = y.values\n", + " return DataLoader(\n", + " TensorDataset(\n", + " torch.tensor(X).to(torch.float32),\n", + " torch.tensor(y).reshape(-1, 1).to(torch.float32),\n", + " ),\n", + " batch_size=batch_size,\n", + " shuffle=shuffle,\n", + " )\n", + "\n", + " def validation(self, X_valid, y_valid):\n", + " valid_data_loader = self._get_dataloader(X_valid, y_valid)\n", + "\n", + " self.network.eval()\n", + " valid_loss = 0.0\n", + " valid_acc = 0.0\n", + "\n", + " with torch.no_grad():\n", + " for X, y in valid_data_loader:\n", + " y_hat = self.network(X)\n", + " valid_loss += self.loss(y_hat, y).item() / len(valid_data_loader)\n", + " y_hat_pred = (y_hat > 0.5).float()\n", + " valid_acc += accuracy_score(\n", + " y.detach().numpy().ravel(),\n", + " y_hat_pred.detach().numpy().ravel(),\n", + " ) / len(valid_data_loader)\n", + "\n", + " return valid_loss, valid_acc\n", + "\n", + " def fit(self, X_train, y_train, n_epochs: int = 100):\n", + " train_data_loader = self._get_dataloader(X_train, y_train, shuffle=True)\n", + "\n", + " # Recreate optimizer so each fit starts from current tuned lr and clean state.\n", + " self.optimizer = Adam(self.network.parameters(), lr=float(self._init_params[\"lr\"]))\n", + "\n", + " for epoch in range(n_epochs):\n", + " self.network.train()\n", + " train_loss = 0.0\n", + " for X, y in train_data_loader:\n", + " self.optimizer.zero_grad()\n", + " y_hat = self.network(X)\n", + " batch_train_loss = self.loss(y_hat, y)\n", + "\n", + " if not torch.isfinite(batch_train_loss):\n", + " raise ValueError(\"Training diverged (non-finite loss). Try a lower learning rate.\")\n", + "\n", + " batch_train_loss.backward()\n", + " self.optimizer.step()\n", + " train_loss += batch_train_loss.item() / len(train_data_loader)\n", + " return self\n", + "\n", + "\n", + "model = TorchNNMother(lr=1e-3)\n", + "model.fit(X_train, y_train)\n", + "print(model.validation(X_valid, y_valid))" + ] + }, + { + "cell_type": "markdown", + "id": "e1872e5a", + "metadata": {}, + "source": [ + "### 2.2 Custom Mother Tuner for Torch\n", + "\n", + "Neural network training often requires splitting data into three sets (train, validation, and test). This does not align with the default `MotherTuner` design, which does not create a validation set for monitoring training epochs. To address this, we add a train-validation split inside the custom objective function." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8b36554d", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/workspaces/MotherML/src/mother/optimization/core.py:131: ExperimentalWarning: Argument ``multivariate`` is an experimental feature. The interface can change in the future.\n", + " self.sampler = optuna.samplers.TPESampler(\n", + "/workspaces/MotherML/src/mother/optimization/core.py:131: ExperimentalWarning: Argument ``group`` is an experimental feature. The interface can change in the future.\n", + " self.sampler = optuna.samplers.TPESampler(\n", + "/workspaces/MotherML/src/mother/optimization/core.py:131: ExperimentalWarning: Argument ``constant_liar`` is an experimental feature. The interface can change in the future.\n", + " self.sampler = optuna.samplers.TPESampler(\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Train / Validation samples = 455 / 114 with 30 features and 2 labels.\n", + "{'lr': 0.001}\n", + "Train / Validation samples = 455 / 114 with 30 features and 2 labels.\n", + "{'lr': 0.0009361658778024464}\n", + "Train / Validation samples = 455 / 114 with 30 features and 2 labels.\n", + "{'lr': 0.0008048266581936503}\n", + "FrozenTrial(number=0, state=, values=[0.22355324029922485], datetime_start=datetime.datetime(2026, 7, 17, 14, 11, 33, 456914), datetime_complete=datetime.datetime(2026, 7, 17, 14, 11, 34, 358672), params={'lr': 0.001}, user_attrs={}, system_attrs={'fixed_params': {'lr': 0.001}}, intermediate_values={}, distributions={'lr': FloatDistribution(high=0.001, log=True, low=1e-05, step=None)}, trial_id=0, value=None)\n", + "{'lr': 0.001}\n", + "{'lr': 0.001}\n" + ] + } + ], + "source": [ + "from optuna import Trial\n", + "import sklearn.base as skl_base\n", + "import mother.optimization as opt\n", + "\n", + "\n", + "class TorchMotherTuner(opt.AbstractMotherTuner):\n", + " def __init__(self, **kwargs):\n", + " # create a customised scorer\n", + " super().__init__(**kwargs)\n", + "\n", + " def objective(self, trial: Trial, context: opt.ObjectiveContext) -> float:\n", + " X_train, X_valid, y_train, y_valid = train_test_split(context.X, context.y, test_size=0.2)\n", + " print(\n", + " f\"Train / Validation samples = {X_train.shape[0]} / {X_valid.shape[0]} with {X_train.shape[1]} features and {y_train.nunique()} labels.\"\n", + " )\n", + "\n", + " # fit\n", + " estimator = skl_base.clone(context.estimator)\n", + " suggested_params_to_train: dict = context.get_hyper_space(trial=trial, X=context.X, y=context.y)\n", + " estimator.set_params(**suggested_params_to_train)\n", + " estimator.fit(X_train, y_train)\n", + "\n", + " # calculate valid loss\n", + " valid_loss, valid_acc = context.estimator.validation(X_valid, y_valid)\n", + "\n", + " return valid_loss\n", + "\n", + " def call_optimize(self, context: opt.ObjectiveContext) -> None:\n", + " self.study.optimize(\n", + " lambda trial: self.objective(trial, context=context),\n", + " n_trials=self.n_trials_optuna,\n", + " gc_after_trial=True,\n", + " callbacks=self.get_callbacks(),\n", + " )\n", + "\n", + "\n", + "tuner = TorchMotherTuner(\n", + " n_trials_optuna=3, # number of trials for hyperparameter optimization\n", + " n_threads_optuna=10, # parallel threads for cross-validation evaluation\n", + " n_startup_trials=1, # number of random trials before using optuna\n", + " tuning_direction=\"minimize\", # Need to minimize the loss!\n", + ")\n", + "\n", + "model_tuned = tuner.optimize(\n", + " model,\n", + " X,\n", + " y,\n", + " cross_validation=None,\n", + " hyperparameter_space_function=model.get_hyperparameter_space,\n", + ")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "mother-ml (3.13.14)", + "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.14" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/mother/optimization/__init__.py b/src/mother/optimization/__init__.py index 1900c31..1fcc332 100644 --- a/src/mother/optimization/__init__.py +++ b/src/mother/optimization/__init__.py @@ -1,3 +1,3 @@ -from mother.optimization.core import MotherTuner +from mother.optimization.core import AbstractMotherTuner, MotherTuner, ObjectiveContext -__all__ = ["MotherTuner"] +__all__ = ["MotherTuner", "AbstractMotherTuner", "ObjectiveContext"] diff --git a/src/mother/optimization/core.py b/src/mother/optimization/core.py index befef5e..1ea3314 100644 --- a/src/mother/optimization/core.py +++ b/src/mother/optimization/core.py @@ -2,6 +2,8 @@ import json import logging import typing +from abc import ABC, abstractmethod +from dataclasses import dataclass, field from functools import wraps import numpy as np @@ -100,34 +102,27 @@ def wrapper(*args, **kwargs): return wrapper -class MotherTuner: - """MotherTuner is a class that facilitates hyperparameter tuning using Optuna. - - Attributes: - n_trials_optuna (int): Number of trials for Optuna optimization. - n_startup_trials (int): Number of startup trials for Optuna. - n_threads_optuna (int): Number of threads for Optuna optimization. - early_stopping_optuna (bool): Flag to enable early stopping in Optuna. - tuning_direction (StudyDirection or string): Direction of optimization (maximize or minimize). - scorer (typing.Callable): Scoring function or string identifier for scoring. - sampler (optuna.samplers.BaseSampler): Sampler for Optuna trials. - study (typing.Optional[Study]): Optuna study object. - **kwargs (Any): additional arguments for the scorer +@dataclass +class ObjectiveContext: + """ + This is a dataclass to pass all arguments of `def optimize()` to + a customized `def objective()`. + """ - Methods: - __init__(self, scorer, sampler=None, early_stopping_optuna=False, tuning_direction=StudyDirection.MAXIMIZE, - n_trials_optuna=100, n_threads_optuna=1, n_startup_trials=12, seed=42): - Initializes the MotherTuner with the given parameters. + get_hyper_space: typing.Callable + estimator: Pipeline + X: pd.DataFrame + y: typing.Union[pd.DataFrame, pd.Series] + cross_validation: skl_model_sel.BaseCrossValidator + fit_kwargs: dict + groups_as_cross_val_args: bool + groups: typing.Optional[np.ndarray] + extras: dict[str, typing.Any] = field(default_factory=dict) - get_callbacks(self): - - optimize(self, estimator, X, y, cross_validation, groups=None, direction="maximize") - -> Pipeline: - """ +class AbstractMotherTuner(ABC): def __init__( self, - scorer: typing.Union[typing.Callable, str], sampler: typing.Optional[optuna.samplers.BaseSampler] = None, early_stopping_optuna: bool = False, tuning_direction: typing.Union[StudyDirection, str] = StudyDirection.MAXIMIZE, @@ -136,13 +131,12 @@ def __init__( n_startup_trials: int = 12, seed: int = 42, **kwargs, - ): + ) -> None: self.n_trials_optuna: int = n_trials_optuna self.n_startup_trials: int = n_startup_trials self.n_threads_optuna: int = n_threads_optuna self.early_stopping_optuna: bool = early_stopping_optuna self.tuning_direction: typing.Union[StudyDirection, str] = tuning_direction - self.scorer: typing.Callable = skl_metrics.get_scorer(scorer) if sampler is None: module_logger.debug("Setting up default sampler TPE") self.sampler = optuna.samplers.TPESampler( @@ -155,7 +149,7 @@ def __init__( else: self.sampler = sampler - self.study: typing.Optional[Study] = None + self.study: typing.Optional[Study] | None = None def get_callbacks(self): """ @@ -182,6 +176,25 @@ def get_callbacks(self): callbacks = [TerminatorCallback()] return callbacks + @abstractmethod + def objective(self, trial: optuna.trial.Trial, context: ObjectiveContext) -> float: + """ + Placeholder to implement a customized objective function + This function is the func argument of optuna.study.optimize + https://optuna.readthedocs.io/en/stable/reference/generated/optuna.study.Study.html#optuna.study.Study.optimize + """ + raise NotImplementedError + + @abstractmethod + def call_optimize(self, context: ObjectiveContext) -> None: + """ + Placeholder to implement a customized function to + 1. additional processing on data/model + 2. call the Study().optimize() function with self.objective() + """ + + raise NotImplementedError + @handle_metadata_routing def optimize( self, @@ -195,6 +208,7 @@ def optimize( ranking_groups: typing.Optional[np.ndarray] = None, fit_kwargs: typing.Optional[dict] = None, _groups_as_cross_val_args: bool = True, + **kwargs, ) -> Pipeline: """ Takes an estimator as input and optimizes the hyperparameters according to @@ -227,36 +241,18 @@ def optimize( assert fit_kwargs is not None groups_as_cross_val_args = _groups_as_cross_val_args - def objective(trial: optuna.trial.Trial) -> float: - suggested_params_to_train: dict = get_hyper_space(trial=trial, X=X, y=y) - module_logger.debug("Cloning pipeline") - pipeline_cv: Pipeline = skl_base.clone(estimator) - pipeline_cv.set_params(**suggested_params_to_train) - - cross_val_kwargs = {} - if groups_as_cross_val_args: - cross_val_kwargs["groups"] = groups - - module_logger.debug("Perform cross validation scoring") - cv_score = skl_model_sel.cross_val_score( - estimator=pipeline_cv, - X=X, - y=utils.y_toArray(y), - cv=cross_validation, - scoring=self.scorer, - n_jobs=np.min([self.n_threads_optuna, cross_validation.get_n_splits()]), - pre_dispatch=np.min([self.n_threads_optuna, cross_validation.get_n_splits()]), - error_score="raise", # only for debugging - params=fit_kwargs, - **cross_val_kwargs, - ) - - gc.collect() - module_logger.info(f"Trial {trial.number}, cv score: {cv_score}") - cv_score_not_na: np.ndarray = cv_score[~np.isnan(cv_score)] - report_cross_validation_scores(trial, list(cv_score_not_na)) - mean_cv_score: float = cv_score_not_na.mean() - return mean_cv_score + # set ObjectiveContext + obj_context = ObjectiveContext( + get_hyper_space=get_hyper_space, + estimator=estimator, + X=X, + y=y, + cross_validation=cross_validation, + fit_kwargs=fit_kwargs, + groups_as_cross_val_args=groups_as_cross_val_args, + groups=groups, + extras=kwargs, + ) module_logger.info( "Setting up Optuna to optimize hyperparameters with direction: %s", @@ -280,12 +276,9 @@ def objective(trial: optuna.trial.Trial) -> float: json.dumps(default_parameters, indent=4), ) self.study.enqueue_trial(default_parameters) - self.study.optimize( - objective, - n_trials=self.n_trials_optuna, - gc_after_trial=True, - callbacks=self.get_callbacks(), - ) + + # call optuna study optimize + self.call_optimize(obj_context) if default_parameters != {}: module_logger.info("Check if the default parameters have been evaluated in the study") @@ -317,3 +310,82 @@ def objective(trial: optuna.trial.Trial) -> float: module_logger.info("Training completed") return pipeline + + +class MotherTuner(AbstractMotherTuner): + """MotherTuner is a class that facilitates hyperparameter tuning using Optuna. + + Attributes: + scorer (typing.Callable): Scoring function or string identifier for scoring. + n_trials_optuna (int): Number of trials for Optuna optimization. + n_startup_trials (int): Number of startup trials for Optuna. + n_threads_optuna (int): Number of threads for Optuna optimization. + early_stopping_optuna (bool): Flag to enable early stopping in Optuna. + tuning_direction (StudyDirection or string): Direction of optimization (maximize or minimize). + sampler (optuna.samplers.BaseSampler): Sampler for Optuna trials. + study (typing.Optional[Study]): Optuna study object. + **kwargs (Any): additional arguments for the scorer + + Methods: + __init__(self, scorer, sampler=None, early_stopping_optuna=False, tuning_direction=StudyDirection.MAXIMIZE, + n_trials_optuna=100, n_threads_optuna=1, n_startup_trials=12, seed=42): + Initializes the MotherTuner with the given parameters. + + get_callbacks(self): + + optimize(self, estimator, X, y, cross_validation, groups=None, direction="maximize") + -> Pipeline: + """ + + def __init__(self, scorer: typing.Union[typing.Callable, str], **kwargs): + + self.scorer: typing.Callable = skl_metrics.get_scorer(scorer) + super().__init__(**kwargs) + + def objective(self, trial: optuna.trial.Trial, context: ObjectiveContext) -> float: + suggested_params_to_train: dict = context.get_hyper_space(trial=trial, X=context.X, y=context.y) + module_logger.debug("Cloning pipeline") + pipeline_cv: Pipeline = skl_base.clone(context.estimator) + pipeline_cv.set_params(**suggested_params_to_train) + + cross_val_kwargs = {} + if context.groups_as_cross_val_args: + cross_val_kwargs["groups"] = context.groups + + module_logger.debug("Perform cross validation scoring") + cv_score = skl_model_sel.cross_val_score( + estimator=pipeline_cv, + X=context.X, + y=utils.y_toArray(context.y), + cv=context.cross_validation, + scoring=self.scorer, + n_jobs=np.min([self.n_threads_optuna, context.cross_validation.get_n_splits()]), + pre_dispatch=np.min([self.n_threads_optuna, context.cross_validation.get_n_splits()]), + error_score="raise", # only for debugging + params=context.fit_kwargs, + **cross_val_kwargs, + ) + + gc.collect() + module_logger.info(f"Trial {trial.number}, cv score: {cv_score}") + cv_score_not_na: np.ndarray = cv_score[~np.isnan(cv_score)] + report_cross_validation_scores(trial, list(cv_score_not_na)) + mean_cv_score: float = cv_score_not_na.mean() + return mean_cv_score + + def call_optimize(self, context: ObjectiveContext) -> None: + """Call study.optimize() funtion. + + Can be customised in the case of data processing needed for cross validation / optimisation + + Args: + context (ObjectiveContext): optimize() function calls this function + with a ObjectiveContext object containing all necessary arguments. + self.call_optimize(obj_context) + """ + self.study.optimize( + lambda trial: self.objective(trial, context=context), + n_trials=self.n_trials_optuna, + gc_after_trial=True, + callbacks=self.get_callbacks(), + ) diff --git a/uv.lock b/uv.lock index bdc5ed7..fb4e131 100644 --- a/uv.lock +++ b/uv.lock @@ -371,12 +371,12 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "biotraj" }, - { name = "msgpack" }, - { name = "networkx" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "requests" }, + { name = "biotraj", marker = "python_full_version < '3.12'" }, + { name = "msgpack", marker = "python_full_version < '3.12'" }, + { name = "networkx", marker = "python_full_version < '3.12'" }, + { name = "numpy", marker = "python_full_version < '3.12'" }, + { name = "packaging", marker = "python_full_version < '3.12'" }, + { name = "requests", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/59/7b/99153f7bceef01034b5f19a6b123219533132d446ffcf141dfef3e386d33/biotite-1.6.0.tar.gz", hash = "sha256:4c172f6e57521220fa0fc4899142211f6f21ba83d8f6f135d4edc68981f70e7e", size = 38514388, upload-time = "2026-01-23T12:47:38.331Z" } wheels = [ @@ -410,12 +410,12 @@ resolution-markers = [ "python_full_version >= '3.12'", ] dependencies = [ - { name = "biotraj" }, - { name = "msgpack" }, - { name = "networkx" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "requests" }, + { name = "biotraj", marker = "python_full_version >= '3.12'" }, + { name = "msgpack", marker = "python_full_version >= '3.12'" }, + { name = "networkx", marker = "python_full_version >= '3.12'" }, + { name = "numpy", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "requests", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/93/ed0751d0f16d54ec82735605c776b37c77af7949a3dd230a1226f2b3b4df/biotite-1.7.1.tar.gz", hash = "sha256:2ae4a5d2c2d5ba08ca5d89a647984c99f45994eb908b614e896ce9e4db3ca800", size = 39858536, upload-time = "2026-06-22T12:42:34.507Z" } wheels = [ @@ -1055,43 +1055,43 @@ wheels = [ [package.optional-dependencies] cublas = [ - { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] cudart = [ - { name = "nvidia-cuda-runtime", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-runtime", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] cufft = [ - { name = "nvidia-cufft", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cufft", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] cufile = [ - { name = "nvidia-cufile", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cufile", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-cupti", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] curand = [ - { name = "nvidia-curand", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-curand", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] cusolver = [ - { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cusolver", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-cusolver", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvtx", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" }, ] [[package]] @@ -1254,8 +1254,8 @@ name = "fast-array-utils" version = "1.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "array-api-compat" }, - { name = "numpy" }, + { name = "array-api-compat", marker = "python_full_version >= '3.12'" }, + { name = "numpy", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/23/52484a651cfba9298ac23d1243afe75ab423ed1a4cae2e8f2b068dce9e2e/fast_array_utils-1.5.tar.gz", hash = "sha256:6151ecbb649d74f38927f1a3b67918ca3d65b1f9d2528cfaf9abb4d1ae5762e1", size = 337392, upload-time = "2026-07-17T13:36:36.53Z" } wheels = [ @@ -1264,10 +1264,10 @@ wheels = [ [package.optional-dependencies] accel = [ - { name = "numba" }, + { name = "numba", marker = "python_full_version >= '3.12'" }, ] sparse = [ - { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" } }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] [[package]] @@ -2775,7 +2775,7 @@ name = "mlx" version = "0.32.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mlx-metal" }, + { name = "mlx-metal", marker = "sys_platform == 'darwin'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/3e/c7/cb62301b01dbccd66b256cab0c98fc29e7533dd76aa599fe44c1bb1f4168/mlx-0.32.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:72c605368d145c756877057d7e3c54f169c9899fe1f83232bfb3a6342561e234", size = 562792, upload-time = "2026-07-07T17:55:35.24Z" }, @@ -2804,7 +2804,7 @@ wheels = [ [[package]] name = "mother-ml" -version = "1.0.1" +version = "1.0.4" source = { editable = "." } dependencies = [ { name = "boruta" }, @@ -4922,27 +4922,27 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "anndata" }, - { name = "h5py" }, - { name = "joblib" }, - { name = "legacy-api-wrap" }, - { name = "matplotlib" }, - { name = "natsort" }, - { name = "networkx" }, - { name = "numba" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pandas" }, - { name = "patsy" }, - { name = "pynndescent" }, - { name = "scikit-learn" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" } }, - { name = "seaborn" }, - { name = "session-info2" }, - { name = "statsmodels" }, - { name = "tqdm" }, - { name = "typing-extensions" }, - { name = "umap-learn" }, + { name = "anndata", marker = "python_full_version < '3.12'" }, + { name = "h5py", marker = "python_full_version < '3.12'" }, + { name = "joblib", marker = "python_full_version < '3.12'" }, + { name = "legacy-api-wrap", marker = "python_full_version < '3.12'" }, + { name = "matplotlib", marker = "python_full_version < '3.12'" }, + { name = "natsort", marker = "python_full_version < '3.12'" }, + { name = "networkx", marker = "python_full_version < '3.12'" }, + { name = "numba", marker = "python_full_version < '3.12'" }, + { name = "numpy", marker = "python_full_version < '3.12'" }, + { name = "packaging", marker = "python_full_version < '3.12'" }, + { name = "pandas", marker = "python_full_version < '3.12'" }, + { name = "patsy", marker = "python_full_version < '3.12'" }, + { name = "pynndescent", marker = "python_full_version < '3.12'" }, + { name = "scikit-learn", marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "seaborn", marker = "python_full_version < '3.12'" }, + { name = "session-info2", marker = "python_full_version < '3.12'" }, + { name = "statsmodels", marker = "python_full_version < '3.12'" }, + { name = "tqdm", marker = "python_full_version < '3.12'" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "umap-learn", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d2/a8/285f1a9c995906b7e0ae3c399208fe67cfba8126dd31359dfef0908f6edc/scanpy-1.11.5.tar.gz", hash = "sha256:b2ef5476dfb1144b7dd0fae90b0198699c7988e6b27f083904150642c7ba6b89", size = 14088122, upload-time = "2025-10-21T08:24:43.999Z" } wheels = [ @@ -4957,30 +4957,30 @@ resolution-markers = [ "python_full_version >= '3.12'", ] dependencies = [ - { name = "anndata" }, - { name = "certifi" }, - { name = "fast-array-utils", extra = ["accel", "sparse"] }, - { name = "h5py" }, - { name = "joblib" }, - { name = "legacy-api-wrap" }, - { name = "matplotlib" }, - { name = "natsort" }, - { name = "networkx" }, - { name = "numba" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pandas" }, - { name = "patsy" }, - { name = "pynndescent" }, - { name = "scikit-learn" }, - { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" } }, - { name = "scverse-misc", version = "0.1.3", source = { registry = "https://pypi.org/simple" } }, - { name = "seaborn" }, - { name = "session-info2" }, - { name = "statsmodels" }, - { name = "tqdm" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - { name = "umap-learn" }, + { name = "anndata", marker = "python_full_version >= '3.12'" }, + { name = "certifi", marker = "python_full_version >= '3.12'" }, + { name = "fast-array-utils", extra = ["accel", "sparse"], marker = "python_full_version >= '3.12'" }, + { name = "h5py", marker = "python_full_version >= '3.12'" }, + { name = "joblib", marker = "python_full_version >= '3.12'" }, + { name = "legacy-api-wrap", marker = "python_full_version >= '3.12'" }, + { name = "matplotlib", marker = "python_full_version >= '3.12'" }, + { name = "natsort", marker = "python_full_version >= '3.12'" }, + { name = "networkx", marker = "python_full_version >= '3.12'" }, + { name = "numba", marker = "python_full_version >= '3.12'" }, + { name = "numpy", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pandas", marker = "python_full_version >= '3.12'" }, + { name = "patsy", marker = "python_full_version >= '3.12'" }, + { name = "pynndescent", marker = "python_full_version >= '3.12'" }, + { name = "scikit-learn", marker = "python_full_version >= '3.12'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "scverse-misc", version = "0.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "seaborn", marker = "python_full_version >= '3.12'" }, + { name = "session-info2", marker = "python_full_version >= '3.12'" }, + { name = "statsmodels", marker = "python_full_version >= '3.12'" }, + { name = "tqdm", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", marker = "python_full_version == '3.12.*'" }, + { name = "umap-learn", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4a/76/c330518db1a721a91b11c57f240134bbc52d7777acc2daf09090f11172d1/scanpy-1.12.2.tar.gz", hash = "sha256:67629ea2989a790a946251ee2bd05186aedbfae9fef998a51506dcfe99dce84c", size = 14428388, upload-time = "2026-06-29T12:26:21.76Z" } wheels = [ @@ -5041,7 +5041,7 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "numpy" }, + { name = "numpy", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -5115,7 +5115,7 @@ resolution-markers = [ "python_full_version >= '3.12'", ] dependencies = [ - { name = "numpy" }, + { name = "numpy", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } wheels = [ @@ -5169,8 +5169,8 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "session-info2" }, - { name = "typing-extensions" }, + { name = "session-info2", marker = "python_full_version < '3.12'" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f7/05/6123a4362e2810ef216f152e249a66799f9c37975dabf89b69abb2d68c42/scverse_misc-0.0.3.tar.gz", hash = "sha256:18c46eeeac8ccef8f435e41a8ee86173b3d7ef6ea1167fde97a17553f70d3210", size = 23128, upload-time = "2026-04-10T15:12:21.731Z" } wheels = [ @@ -5185,8 +5185,8 @@ resolution-markers = [ "python_full_version >= '3.12'", ] dependencies = [ - { name = "session-info2" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "session-info2", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", marker = "python_full_version == '3.12.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/73/91/c48708330643569fd14c73c66707a5eae39634fb8f34874bbedcaa3fa996/scverse_misc-0.1.3.tar.gz", hash = "sha256:843f29b40e4bbeab85849dcaa3ce0be1e64dc9de285eb7afff9c249f4c2bc76a", size = 45860, upload-time = "2026-07-18T10:04:16.493Z" } wheels = [