From f1e0f14d291605df09e74f5cdfa7c5911637061b Mon Sep 17 00:00:00 2001 From: Michelle Wang Date: Thu, 29 Oct 2026 22:14:57 -0400 Subject: [PATCH 1/2] use date tag in output file paths --- README.md | 2 +- scripts/run_experiments.py | 16 ++++++++++++++- tests/scripts/test_run_experiments.py | 29 ++++++++++++++++++++++++--- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9a57917..fe338ff 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ The experiment script can be invoked via e.g.: docker compose exec -u fedbiomed researcher python /brainflex-server/scripts/run_experiments.py --help ``` -The output directory should be `fbm-researcher/results` inside the container. +The output directory should be `/fbm-researcher/results` inside the container. ### Stopping diff --git a/scripts/run_experiments.py b/scripts/run_experiments.py index 9bbdffa..3b6f32a 100755 --- a/scripts/run_experiments.py +++ b/scripts/run_experiments.py @@ -11,6 +11,7 @@ import warnings from collections import defaultdict from dataclasses import dataclass, field +from datetime import date from enum import Enum from pathlib import Path @@ -41,6 +42,7 @@ DEFAULT_AGGREGATOR = "scaffold" DEFAULT_FEDPROX_MU = 1.0 DEFAULT_ALPHA = 0.1 +DEFAULT_DATE_TAG = date.today().strftime("%Y_%m_%d") @dataclass(frozen=True) @@ -60,6 +62,7 @@ class TaskConfig: @dataclass(frozen=True) class PathHelper: output_dir: Path + date_tag: str n_rounds: int batch_size: int n_updates: int @@ -89,7 +92,7 @@ def get_fpath_out( ) if held_out_node is not None: stem = f"{stem}-heldout_{held_out_node}" - return self.output_dir / task / f"{stem}{extension}" + return self.output_dir / self.date_tag / task / f"{stem}{extension}" TASKS = { @@ -116,6 +119,14 @@ def _get_parser() -> argparse.ArgumentParser: parser.add_argument( "output_dir", type=Path, help="Directory to save the global models to." ) + parser.add_argument( + "--date-tag", + default=DEFAULT_DATE_TAG, + help=( + "Name of the subfolder of the output directory to save results to." + f" Default: {DEFAULT_DATE_TAG}." + ), + ) parser.add_argument( "--task", dest="tasks", @@ -567,6 +578,7 @@ def _run_task( def run_experiments( output_dir: Path, tasks: list[str], + date_tag: str = DEFAULT_DATE_TAG, n_rounds: int = DEFAULT_N_ROUNDS, batch_size: int = DEFAULT_BATCH_SIZE, n_updates: int = DEFAULT_N_UPDATES, @@ -604,6 +616,7 @@ def run_experiments( path_helper = PathHelper( output_dir=output_dir, + date_tag=date_tag, n_rounds=n_rounds, batch_size=batch_size, n_updates=n_updates, @@ -686,6 +699,7 @@ def main(argv: list[str] | None = None) -> None: run_experiments( args.output_dir, args.tasks, + date_tag=args.date_tag, n_rounds=args.n_rounds, batch_size=args.batch_size, n_updates=args.n_updates, diff --git a/tests/scripts/test_run_experiments.py b/tests/scripts/test_run_experiments.py index 2c21d89..02fe975 100644 --- a/tests/scripts/test_run_experiments.py +++ b/tests/scripts/test_run_experiments.py @@ -155,6 +155,7 @@ def test_source(self, module: ModuleType, task: str): def path_helper(module: ModuleType, tmp_path: Path) -> object: return module.PathHelper( output_dir=tmp_path, + date_tag="2026_01_01", n_rounds=10, batch_size=32, n_updates=50, @@ -169,13 +170,14 @@ class TestPathHelper: def test_get_fpath_out(self, path_helper: object, tmp_path: Path): assert path_helper.get_fpath_out("age", "results") == ( tmp_path + / "2026_01_01" / "age" / "results-scaffold-10rounds-50updates-32-0.001lr-0.2test-0.1alpha.json" ) def test_extension(self, path_helper: object, tmp_path: Path): fpath = path_helper.get_fpath_out("sex", "model", ".pkl") - assert fpath.parent == tmp_path / "sex" + assert fpath.parent == tmp_path / "2026_01_01" / "sex" assert fpath.suffix == ".pkl" def test_held_out_node(self, path_helper: object): @@ -757,10 +759,12 @@ def _write_results( task: str, data: dict, held_out_node: str | None = None, + date_tag: str | None = None, ) -> None: """Write a results file for the default CLI arguments.""" fpath = module.PathHelper( output_dir=dpath, + date_tag=date_tag if date_tag is not None else module.DEFAULT_DATE_TAG, n_rounds=module.DEFAULT_N_ROUNDS, batch_size=module.DEFAULT_BATCH_SIZE, n_updates=module.DEFAULT_N_UPDATES, @@ -795,17 +799,19 @@ def test_tasks_in_order( ] kwargs = mock_run_task.call_args.kwargs assert kwargs["n_rounds"] == 2 - assert kwargs["fpath_results"].parent == tmp_path.resolve() / "age" + dpath_date = tmp_path.resolve() / module.DEFAULT_DATE_TAG + assert kwargs["fpath_results"].parent == dpath_date / "age" assert kwargs["fpath_results"].name.startswith("results-scaffold-2rounds-") assert kwargs["fpath_model"].name.startswith("model-scaffold-2rounds-") assert kwargs["fpath_model"].suffix == ".pkl" - assert kwargs["dpath_tensorboard"].parent == tmp_path.resolve() / "age" + assert kwargs["dpath_tensorboard"].parent == dpath_date / "age" assert kwargs["dpath_tensorboard"].name.startswith( "tensorboard-scaffold-2rounds-" ) settings = kwargs["settings"] assert settings["output_dir"] == tmp_path.resolve() assert settings["tasks"] == ["sex", "age"] + assert settings["date_tag"] == module.DEFAULT_DATE_TAG assert settings["n_rounds"] == 2 assert settings["learning_rate"] == module.DEFAULT_LEARNING_RATE assert settings["overwrite"] is False @@ -813,6 +819,14 @@ def test_tasks_in_order( assert settings["secagg"] is True assert kwargs["secagg"] is True + def test_date_tag( + self, module: ModuleType, mock_run_task: MagicMock, tmp_path: Path + ): + module.main([str(tmp_path), "--task", "age", "--date-tag", "mytag", "--no-cv"]) + kwargs = mock_run_task.call_args.kwargs + assert kwargs["fpath_results"].parent == tmp_path.resolve() / "mytag" / "age" + assert kwargs["settings"]["date_tag"] == "mytag" + def test_no_secagg( self, module: ModuleType, mock_run_task: MagicMock, tmp_path: Path ): @@ -867,6 +881,15 @@ def test_existing_settings_only( module.main([str(tmp_path), "--task", "age", "--no-cv"]) mock_run_task.assert_called_once() + def test_existing_results_other_date_tag( + self, module: ModuleType, mock_run_task: MagicMock, tmp_path: Path + ): + _write_results( + module, tmp_path.resolve(), "age", {"results": {}}, date_tag="old" + ) + module.main([str(tmp_path), "--task", "age", "--no-cv"]) + mock_run_task.assert_called_once() + def test_overwrite( self, module: ModuleType, mock_run_task: MagicMock, tmp_path: Path ): From 9bb692593caa71042f5b0c93545ce7550b254274 Mon Sep 17 00:00:00 2001 From: Michelle Wang Date: Thu, 29 Oct 2026 22:15:31 -0400 Subject: [PATCH 2/2] add TZ env var for setting timezone inside container --- .env.example | 3 +++ docker-compose.yml | 2 ++ 2 files changed, 5 insertions(+) diff --git a/.env.example b/.env.example index 9abaa7e..f5cc673 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,6 @@ FBM_CONTAINER_INSTANCE_ID=default # Set to the output of `id -u` and `id -g`. If unset, files are owned by UID/GID 2101 #CONTAINER_UID= #CONTAINER_GID= + +# Timezone of the researcher container. If unset, UTC will be used +#TZ= diff --git a/docker-compose.yml b/docker-compose.yml index 62c8e9b..f47503e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,6 +81,8 @@ services: - FBM_RESEARCHER_COMPONENT_ROOT=/fbm-researcher # address of the researcher on the VPN - FBM_SERVER_HOST=10.222.0.2 + # timezone, used e.g. for the date subfolder of the experiment results + - TZ=${TZ:-UTC} volumes: - ./fbm-researcher:/fbm-researcher # contains fedbiomed component configuration and data # brainflex training plans and experiment script