From 1171f27b253be769ac911e254e28ad2e4c6a8c7b Mon Sep 17 00:00:00 2001 From: Daesun Ryu <98425048+Daesun-Ryu@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:51:31 +0200 Subject: [PATCH 1/2] Expose scGPT fine-tuning workload parameters (#111) --- .github/workflows/test.yaml | 12 ++ CHANGELOG.md | 2 + src/methods/scgpt_finetuned/config.vsh.yaml | 14 ++ src/methods/scgpt_finetuned/script.py | 8 +- .../scgpt_finetuned/test_training_params.py | 161 ++++++++++++++++++ 5 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 src/methods/scgpt_finetuned/test_training_params.py diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 1b8d1db6a..b6bc9dfd5 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -9,3 +9,15 @@ on: jobs: test: uses: viash-io/viash-actions/.github/workflows/test.yaml@v6 + + scgpt_training_parameters: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python -m pip install PyYAML==6.0.3 + - run: >- + python -B -m unittest discover + -s src/methods/scgpt_finetuned -p test_training_params.py -v diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c1adbbe9..27fda15eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,8 @@ ## Bug fixes +* Expose `methods/scgpt_finetuned`'s epochs and batch size as arguments, preserving defaults of 15 and 64. + Batch size also applies to validation and embedding (issue #111). * Update scPRINT to use latest stable version (PR #70) * Fix kbet dependencies to numpy<2 and scipy<=1.13 (PR #78). * Fix `render_readme` crashing on `comp_process_integration.yaml`'s absolute `__merge__` paths. diff --git a/src/methods/scgpt_finetuned/config.vsh.yaml b/src/methods/scgpt_finetuned/config.vsh.yaml index 631a0dfae..02149f84d 100644 --- a/src/methods/scgpt_finetuned/config.vsh.yaml +++ b/src/methods/scgpt_finetuned/config.vsh.yaml @@ -38,6 +38,20 @@ arguments: type: integer default: 3000 description: Number of highly variable genes to use. + - name: --epochs + type: integer + default: 15 + min: 1 + description: Number of fine-tuning epochs. + info: + test_default: 2 + - name: --batch_size + type: integer + default: 64 + min: 1 + description: Batch size for fine-tuning, validation and embedding. + info: + test_default: 4 resources: - type: python_script diff --git a/src/methods/scgpt_finetuned/script.py b/src/methods/scgpt_finetuned/script.py index 2583ae49f..ac1f36ac3 100644 --- a/src/methods/scgpt_finetuned/script.py +++ b/src/methods/scgpt_finetuned/script.py @@ -24,6 +24,8 @@ "model_name": "scGPT_human", "model": "scGPT_human", "n_hvg": 3000, + "epochs": 15, + "batch_size": 64, } meta = {"name": "scgpt"} ## VIASH END @@ -221,9 +223,9 @@ "ecs_thres": 0.8, # Elastic cell similarity objective, 0.0 to 1.0, 0.0 to disable "dab_weight": 1.0, # DAR objective weight for batch correction "mask_ratio": 0.4, - "epochs": 15, + "epochs": par["epochs"], "lr": 1e-4, - "batch_size": 64, + "batch_size": par["batch_size"], "dropout": 0.2, "schedule_ratio": 0.9, # Learning rate decay "log_interval": 100, @@ -364,7 +366,7 @@ adata, best_model_dir.name, gene_col="feature_name", - batch_size=64, + batch_size=par["batch_size"], use_fast_transformer=False, # Disable fast-attn as not installed device=device, return_new_adata=True, diff --git a/src/methods/scgpt_finetuned/test_training_params.py b/src/methods/scgpt_finetuned/test_training_params.py new file mode 100644 index 000000000..ccf359dd1 --- /dev/null +++ b/src/methods/scgpt_finetuned/test_training_params.py @@ -0,0 +1,161 @@ +"""Exercise production parameter wiring without importing scGPT or PyTorch.""" + +import ast +from pathlib import Path +from types import SimpleNamespace +import unittest +from unittest.mock import Mock + +import yaml + + +COMPONENT = Path(__file__).resolve().parent +SCRIPT = COMPONENT / "script.py" +TREE = ast.parse(SCRIPT.read_text(encoding="utf-8")) +CONFIG = yaml.safe_load((COMPONENT / "config.vsh.yaml").read_text(encoding="utf-8")) + + +def assignment(name): + return next( + node + for node in TREE.body + if isinstance(node, ast.Assign) + and any(isinstance(t, ast.Name) and t.id == name for t in node.targets) + ) + + +def execute(nodes, namespace): + # Compile the original nodes unchanged, retaining production line numbers. + exec( + compile(ast.Module(body=nodes, type_ignores=[]), str(SCRIPT), "exec"), namespace + ) + + +def run_training(par): + """Run the actual epoch loop and embedding call with only ML work stubbed.""" + namespace = dict.fromkeys( + [ + "tokenized_train", + "tokenized_valid", + "train_batch_labels", + "valid_batch_labels", + "model", + "scaler", + "optimizer", + "criterion", + "criterion_dab", + "device", + "adata", + ] + ) + namespace.update( + par=par, + vocab=["gene"], + model_settings={"per_seq_batch_sample": True}, + prepare_data=Mock(return_value=("train_data", "valid_data")), + prepare_dataloader=Mock(side_effect=lambda data, **kwargs: (data, kwargs)), + train=Mock(), + evaluate=Mock(return_value=(1.0, 0.1)), + scheduler=Mock(), + time=SimpleNamespace(time=lambda: 0), + copy=SimpleNamespace(deepcopy=lambda model: model), + scgpt=SimpleNamespace(logger=Mock(), tasks=SimpleNamespace(embed_data=Mock())), + best_val_loss=float("inf"), + best_model_dir=SimpleNamespace(name="unused_model_path"), + ) + epoch_loop = next( + node + for node in TREE.body + if isinstance(node, ast.For) + and isinstance(node.target, ast.Name) + and node.target.id == "epoch" + ) + execute( + [assignment("hyperparameters"), epoch_loop, assignment("embedded")], namespace + ) + return namespace + + +class TrainingParametersTest(unittest.TestCase): + def test_config_exposes_integer_defaults(self): + arguments = {arg["name"]: arg for arg in CONFIG["arguments"]} + for name, default in (("epochs", 15), ("batch_size", 64)): + with self.subTest(parameter=name): + self.assertIn("--" + name, arguments) + argument = arguments["--" + name] + self.assertEqual(argument["type"], "integer") + self.assertEqual(argument["default"], default) + self.assertEqual(argument["min"], 1) + self.assertEqual( + argument["info"]["test_default"], + 2 if name == "epochs" else 4, + ) + + def test_committed_par_matches_config_defaults(self): + namespace = {} + execute([assignment("par")], namespace) + for name, default in (("epochs", 15), ("batch_size", 64)): + with self.subTest(parameter=name): + self.assertEqual(namespace["par"].get(name), default) + + def check_training(self, epochs, batch_size): + namespace = run_training({"epochs": epochs, "batch_size": batch_size}) + self.assertEqual(namespace["train"].call_count, epochs) + self.assertEqual(namespace["evaluate"].call_count, epochs) + self.assertEqual(namespace["scheduler"].step.call_count, epochs) + for epoch, call in enumerate(namespace["train"].call_args_list, start=1): + self.assertEqual(call.args[-1], epoch) + self.assertEqual(call.args[1][0], "train_data") + self.assertEqual(call.args[1][1]["batch_size"], batch_size) + self.assertEqual(call.args[8]["epochs"], epochs) + self.assertEqual(call.args[8]["batch_size"], batch_size) + for call in namespace["evaluate"].call_args_list: + self.assertEqual(call.args[1][0], "valid_data") + self.assertEqual(call.args[1][1]["batch_size"], batch_size) + + def test_default_training(self): + self.check_training(15, 64) + + def test_overridden_training(self): + self.check_training(2, 8) + + def test_overridden_epochs(self): + self.check_training(2, 64) + + def test_overridden_batch_size(self): + self.check_training(15, 8) + + def test_embedding_batch_size(self): + for batch_size in (64, 8): + with self.subTest(batch_size=batch_size): + namespace = run_training({"epochs": 2, "batch_size": batch_size}) + kwargs = namespace["scgpt"].tasks.embed_data.call_args.kwargs + self.assertEqual(kwargs["batch_size"], batch_size) + self.assertFalse(kwargs["use_fast_transformer"]) + + def test_other_hyperparameters_unchanged(self): + namespace = run_training({"epochs": 2, "batch_size": 8}) + hyperparameters = namespace["hyperparameters"].copy() + hyperparameters.pop("epochs") + hyperparameters.pop("batch_size") + self.assertEqual( + hyperparameters, + { + "n_tokens": 1, + "GEPC": True, + "ecs_thres": 0.8, + "dab_weight": 1.0, + "mask_ratio": 0.4, + "lr": 1e-4, + "dropout": 0.2, + "schedule_ratio": 0.9, + "log_interval": 100, + "fast_transformer": False, + "pre_norm": False, + "amp": True, + }, + ) + + +if __name__ == "__main__": + unittest.main() From 287bf0f287953b8f40809549f66c65b31b7859b6 Mon Sep 17 00:00:00 2001 From: Daesun Ryu <98425048+Daesun-Ryu@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:22:49 +0200 Subject: [PATCH 2/2] Address review feedback for scGPT training parameters --- .github/workflows/test.yaml | 12 -- CHANGELOG.md | 4 +- _viash.yaml | 4 + .../scgpt_finetuned/test_training_params.py | 161 ------------------ 4 files changed, 6 insertions(+), 175 deletions(-) delete mode 100644 src/methods/scgpt_finetuned/test_training_params.py diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index b6bc9dfd5..1b8d1db6a 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -9,15 +9,3 @@ on: jobs: test: uses: viash-io/viash-actions/.github/workflows/test.yaml@v6 - - scgpt_training_parameters: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - run: python -m pip install PyYAML==6.0.3 - - run: >- - python -B -m unittest discover - -s src/methods/scgpt_finetuned -p test_training_params.py -v diff --git a/CHANGELOG.md b/CHANGELOG.md index 27fda15eb..7976be4a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,8 @@ ## Minor changes +* Expose `methods/scgpt_finetuned`'s epochs and batch size as arguments, preserving defaults of 15 and 64. + Batch size also applies to validation and embedding (issue #111). * Un-pin the scPRINT version and update parameters (PR #51) * Update scPRINT to better handle large datasets, including a new default model (PR #54) * Credit contributors missing from the authors list, and fix Martin Kim's orcid (PR #100). @@ -49,8 +51,6 @@ ## Bug fixes -* Expose `methods/scgpt_finetuned`'s epochs and batch size as arguments, preserving defaults of 15 and 64. - Batch size also applies to validation and embedding (issue #111). * Update scPRINT to use latest stable version (PR #70) * Fix kbet dependencies to numpy<2 and scipy<=1.13 (PR #78). * Fix `render_readme` crashing on `comp_process_integration.yaml`'s absolute `__merge__` paths. diff --git a/_viash.yaml b/_viash.yaml index f4a5a7c2a..08beb9d8b 100644 --- a/_viash.yaml +++ b/_viash.yaml @@ -141,6 +141,10 @@ authors: info: github: cmclean orcid: 0000-0001-9928-8216 + - name: Daesun Ryu + roles: [contributor] + info: + github: Daesun-Ryu config_mods: | .runners[.type == "nextflow"].config.labels := { lowmem : "memory = 20.Gb", midmem : "memory = 50.Gb", highmem : "memory = 100.Gb", lowcpu : "cpus = 5", midcpu : "cpus = 15", highcpu : "cpus = 30", lowtime : "time = 1.h", midtime : "time = 4.h", hightime : "time = 8.h", veryhightime : "time = 24.h" } diff --git a/src/methods/scgpt_finetuned/test_training_params.py b/src/methods/scgpt_finetuned/test_training_params.py deleted file mode 100644 index ccf359dd1..000000000 --- a/src/methods/scgpt_finetuned/test_training_params.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Exercise production parameter wiring without importing scGPT or PyTorch.""" - -import ast -from pathlib import Path -from types import SimpleNamespace -import unittest -from unittest.mock import Mock - -import yaml - - -COMPONENT = Path(__file__).resolve().parent -SCRIPT = COMPONENT / "script.py" -TREE = ast.parse(SCRIPT.read_text(encoding="utf-8")) -CONFIG = yaml.safe_load((COMPONENT / "config.vsh.yaml").read_text(encoding="utf-8")) - - -def assignment(name): - return next( - node - for node in TREE.body - if isinstance(node, ast.Assign) - and any(isinstance(t, ast.Name) and t.id == name for t in node.targets) - ) - - -def execute(nodes, namespace): - # Compile the original nodes unchanged, retaining production line numbers. - exec( - compile(ast.Module(body=nodes, type_ignores=[]), str(SCRIPT), "exec"), namespace - ) - - -def run_training(par): - """Run the actual epoch loop and embedding call with only ML work stubbed.""" - namespace = dict.fromkeys( - [ - "tokenized_train", - "tokenized_valid", - "train_batch_labels", - "valid_batch_labels", - "model", - "scaler", - "optimizer", - "criterion", - "criterion_dab", - "device", - "adata", - ] - ) - namespace.update( - par=par, - vocab=["gene"], - model_settings={"per_seq_batch_sample": True}, - prepare_data=Mock(return_value=("train_data", "valid_data")), - prepare_dataloader=Mock(side_effect=lambda data, **kwargs: (data, kwargs)), - train=Mock(), - evaluate=Mock(return_value=(1.0, 0.1)), - scheduler=Mock(), - time=SimpleNamespace(time=lambda: 0), - copy=SimpleNamespace(deepcopy=lambda model: model), - scgpt=SimpleNamespace(logger=Mock(), tasks=SimpleNamespace(embed_data=Mock())), - best_val_loss=float("inf"), - best_model_dir=SimpleNamespace(name="unused_model_path"), - ) - epoch_loop = next( - node - for node in TREE.body - if isinstance(node, ast.For) - and isinstance(node.target, ast.Name) - and node.target.id == "epoch" - ) - execute( - [assignment("hyperparameters"), epoch_loop, assignment("embedded")], namespace - ) - return namespace - - -class TrainingParametersTest(unittest.TestCase): - def test_config_exposes_integer_defaults(self): - arguments = {arg["name"]: arg for arg in CONFIG["arguments"]} - for name, default in (("epochs", 15), ("batch_size", 64)): - with self.subTest(parameter=name): - self.assertIn("--" + name, arguments) - argument = arguments["--" + name] - self.assertEqual(argument["type"], "integer") - self.assertEqual(argument["default"], default) - self.assertEqual(argument["min"], 1) - self.assertEqual( - argument["info"]["test_default"], - 2 if name == "epochs" else 4, - ) - - def test_committed_par_matches_config_defaults(self): - namespace = {} - execute([assignment("par")], namespace) - for name, default in (("epochs", 15), ("batch_size", 64)): - with self.subTest(parameter=name): - self.assertEqual(namespace["par"].get(name), default) - - def check_training(self, epochs, batch_size): - namespace = run_training({"epochs": epochs, "batch_size": batch_size}) - self.assertEqual(namespace["train"].call_count, epochs) - self.assertEqual(namespace["evaluate"].call_count, epochs) - self.assertEqual(namespace["scheduler"].step.call_count, epochs) - for epoch, call in enumerate(namespace["train"].call_args_list, start=1): - self.assertEqual(call.args[-1], epoch) - self.assertEqual(call.args[1][0], "train_data") - self.assertEqual(call.args[1][1]["batch_size"], batch_size) - self.assertEqual(call.args[8]["epochs"], epochs) - self.assertEqual(call.args[8]["batch_size"], batch_size) - for call in namespace["evaluate"].call_args_list: - self.assertEqual(call.args[1][0], "valid_data") - self.assertEqual(call.args[1][1]["batch_size"], batch_size) - - def test_default_training(self): - self.check_training(15, 64) - - def test_overridden_training(self): - self.check_training(2, 8) - - def test_overridden_epochs(self): - self.check_training(2, 64) - - def test_overridden_batch_size(self): - self.check_training(15, 8) - - def test_embedding_batch_size(self): - for batch_size in (64, 8): - with self.subTest(batch_size=batch_size): - namespace = run_training({"epochs": 2, "batch_size": batch_size}) - kwargs = namespace["scgpt"].tasks.embed_data.call_args.kwargs - self.assertEqual(kwargs["batch_size"], batch_size) - self.assertFalse(kwargs["use_fast_transformer"]) - - def test_other_hyperparameters_unchanged(self): - namespace = run_training({"epochs": 2, "batch_size": 8}) - hyperparameters = namespace["hyperparameters"].copy() - hyperparameters.pop("epochs") - hyperparameters.pop("batch_size") - self.assertEqual( - hyperparameters, - { - "n_tokens": 1, - "GEPC": True, - "ecs_thres": 0.8, - "dab_weight": 1.0, - "mask_ratio": 0.4, - "lr": 1e-4, - "dropout": 0.2, - "schedule_ratio": 0.9, - "log_interval": 100, - "fast_transformer": False, - "pre_norm": False, - "amp": True, - }, - ) - - -if __name__ == "__main__": - unittest.main()