aicentreflip commited on
Commit
380c9c8
·
verified ·
1 Parent(s): 32a85f0

Publish the FLIP tutorial models

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ figures/samples_4x4.png filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ library_name: monai
4
+ tags:
5
+ - medical-imaging
6
+ - chest-x-ray
7
+ - diffusion
8
+ - ddpm
9
+ - federated-learning
10
+ - nvflare
11
+ ---
12
+
13
+ # Chest X-ray diffusion model: FLIP tutorial model
14
+
15
+ A 2-D denoising diffusion model (DDPM) that generates 128×128 chest X-rays, trained with the
16
+ chest X-ray diffusion tutorial of [FLIP](https://github.com/londonaicentre/FLIP), the Federated
17
+ Learning Interoperability Platform (`fl-tutorials/nvflare/image_generation/xray_diffusion/`).
18
+
19
+ It was trained federated, with NVIDIA FLARE simulating two sites on one GPU, entirely on **synthetic**
20
+ X-rays. It is a tutorial model, not for clinical use.
21
+
22
+ ![16 chest X-rays sampled from this model, seed 0](figures/samples_4x4.png)
23
+
24
+ ## Contents
25
+
26
+ ```text
27
+ diffusion_model.safetensors the weights, float32 as trained
28
+ provenance.json commit, install line, launch command, versions, hardware, wall time, final losses
29
+ code/ the app files exactly as the run executed them
30
+ evaluation/sample_xray.py the script that drew the samples above
31
+ figures/
32
+ ```
33
+
34
+ ## Model and training
35
+
36
+ MONAI `DiffusionModelUNet` in 2-D, unconditional (its only input besides the image is the noise
37
+ step), 22.1 M parameters, trained from scratch: four levels of 64, 128, 192 and 256 channels, with
38
+ self-attention at the two coarsest. It predicts the noise added at a random step of a 1000-step DDPM
39
+ schedule, with an MSE loss.
40
+
41
+ | | |
42
+ | --- | --- |
43
+ | Data | Ark+ tutorial splits, about 1.9k images per site, two sites |
44
+ | Preprocessing | grayscale, resized to 128×128, scaled to [0, 1]; light random affine in training |
45
+ | Schedule | 2 rounds × 300 epochs, batch 6 |
46
+ | Learning rate | 1e-3 decaying to 5e-5 over the first 200 epochs |
47
+ | Final validation loss | 0.0039 (site 1), 0.0036 (site 2), on each site's held-out 20 % |
48
+ | Wall time | 5 h 05 min on one NVIDIA GeForce RTX 5090 |
49
+ | Software | PyTorch 2.13.0, MONAI 1.6.0, NVIDIA FLARE 2.9.0 |
50
+
51
+ Samples saved during training looked like chest X-rays from about epoch 150.
52
+
53
+ ## Use
54
+
55
+ ```python
56
+ import json
57
+ from monai.networks.nets import DiffusionModelUNet
58
+ from safetensors.torch import load_file
59
+
60
+ net_config = json.load(open("code/config.json"))["net_config"]
61
+ model = DiffusionModelUNet(spatial_dims=2, **net_config["diffusion_model"])
62
+ weights = load_file("diffusion_model.safetensors")
63
+ model.load_state_dict({k.removeprefix("diffusion_model."): v for k, v in weights.items()})
64
+ ```
65
+
66
+ Sample with MONAI's `DDPMScheduler` and `DiffusionInferer` (1000 steps), as in
67
+ `evaluation/sample_xray.py`.
68
+
69
+ ## Reproducing it
70
+
71
+ Install flip-utils from the commit in `provenance.json` with its install line, check out FLIP at
72
+ the same commit, copy `code/` over the tutorial's `app_files/`, download the data with
73
+ `make -C fl-tutorials download-arkplus-finetuning-data`, and run the launch command. The run predates
74
+ the tutorial's move to `image_generation/`, so `provenance.json` gives the directory it ran from.
75
+ Training on a GPU is not bit-for-bit repeatable, so expect samples of the same quality, not the same
76
+ samples.
77
+
78
+ ## Data and licence
79
+
80
+ The Ark+ tutorial splits
81
+ ([`aicentreflip/tutorials-arkplus-cxr-classification`](https://huggingface.co/datasets/aicentreflip/tutorials-arkplus-cxr-classification))
82
+ are fully synthetic chest X-rays (DeCaf `balanced_synthetic_split`), with no patient data, licensed
83
+ Apache-2.0. This model is licensed Apache-2.0 too.
code/config.json ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "job_type": "standard",
3
+ "GLOBAL_ROUNDS": 2,
4
+ "LOCAL_ROUNDS": 300,
5
+ "VALIDATE_EVERY": 25,
6
+ "LR_START": 0.001,
7
+ "LR_END": 5e-05,
8
+ "LR_DECAY_EPOCHS": 200,
9
+ "GRAD_CLIP_NORM": 1.0,
10
+ "VAL_SPLIT": 0.2,
11
+ "BATCH_SIZE": 6,
12
+ "SAVE_DEBUG_SAMPLES": true,
13
+ "spatial_shape": [
14
+ 128,
15
+ 128
16
+ ],
17
+ "net_config": {
18
+ "spatial_dims": 2,
19
+ "diffusion_model": {
20
+ "in_channels": 1,
21
+ "out_channels": 1,
22
+ "channels": [
23
+ 64,
24
+ 128,
25
+ 192,
26
+ 256
27
+ ],
28
+ "num_res_blocks": [
29
+ 2,
30
+ 2,
31
+ 2,
32
+ 2
33
+ ],
34
+ "attention_levels": [
35
+ false,
36
+ false,
37
+ true,
38
+ true
39
+ ]
40
+ }
41
+ },
42
+ "DATALOADER_WORKERS": 8
43
+ }
code/models.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ # http://www.apache.org/licenses/LICENSE-2.0
6
+ # Unless required by applicable law or agreed to in writing, software
7
+ # distributed under the License is distributed on an "AS IS" BASIS,
8
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9
+ # See the License for the specific language governing permissions and
10
+ # limitations under the License.
11
+
12
+ """Diffusion model network for the FLIP pixel-space diffusion tutorial.
13
+
14
+ A single ``DiffusionModelUNet`` that denoises **images directly**. There is no autoencoder and no
15
+ discriminator here: the diffusion process runs at full image resolution, so nothing needs to be
16
+ compressed first and nothing is adversarially trained.
17
+
18
+ Because the UNet sees images rather than latents, its ``in_channels``/``out_channels`` are the
19
+ **image** channel count (1 for the single-channel chest X-rays this tutorial uses), not an
20
+ autoencoder's ``latent_channels``. Contrast the latent diffusion tutorial, where the same network
21
+ operates on a 3-channel latent.
22
+ """
23
+
24
+ import json
25
+ from pathlib import Path
26
+
27
+ from monai.networks.nets import DiffusionModelUNet
28
+ from torch import nn
29
+
30
+
31
+ def load_net_config():
32
+ """Read the ``net_config`` block from the ``config.json`` next to this file."""
33
+ config_path = Path(__file__).parent / "config.json"
34
+ with open(config_path, "r") as f:
35
+ config = json.load(f)
36
+ net_config = config.get("net_config", {})
37
+ print(f"Loaded network config: {net_config}")
38
+ return net_config
39
+
40
+
41
+ class DiffusionModelNetwork(nn.Module):
42
+ """Creates a pixel-space diffusion model: a single UNet denoising images directly."""
43
+
44
+ def __init__(self):
45
+ super().__init__()
46
+ net_config = load_net_config()
47
+ self.diffusion_model = DiffusionModelUNet(
48
+ spatial_dims=net_config["spatial_dims"],
49
+ in_channels=net_config["diffusion_model"]["in_channels"],
50
+ out_channels=net_config["diffusion_model"]["out_channels"],
51
+ channels=net_config["diffusion_model"]["channels"],
52
+ num_res_blocks=net_config["diffusion_model"]["num_res_blocks"],
53
+ attention_levels=net_config["diffusion_model"]["attention_levels"],
54
+ # Fused scaled-dot-product attention: the same result, without materialising each head's map.
55
+ use_flash_attention=True,
56
+ )
57
+
58
+
59
+ _net = DiffusionModelNetwork()
60
+
61
+
62
+ def get_model() -> nn.Module:
63
+ """
64
+ Returns the model defined in this file.
65
+ NOTE: This function needs to exist and cannot take any input arguments. If you would like to parameterize the
66
+ configuration of your model, for example loaded from a config file, do it when instantiating the model above.
67
+ """
68
+ return _net
code/plot_utils.py ADDED
@@ -0,0 +1,605 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ # http://www.apache.org/licenses/LICENSE-2.0
6
+ # Unless required by applicable law or agreed to in writing, software
7
+ # distributed under the License is distributed on an "AS IS" BASIS,
8
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9
+ # See the License for the specific language governing permissions and
10
+ # limitations under the License.
11
+
12
+ """Figures for the image-synthesis tutorials, for local development only.
13
+
14
+ ``save_grid`` and ``save_triplanar`` write debug images during a simulator run; run as a script, this
15
+ module plots each site's losses from a simulator workspace. It sits in ``image_synthesis/dev/``,
16
+ which is never uploaded: each tutorial's ``job.py`` stages it into simulator runs, and on a trust the
17
+ trainers' import of it fails and debug output is simply off.
18
+
19
+ Images are written only when ``config.json`` sets ``SAVE_DEBUG_SAMPLES`` **and** this is a
20
+ Makefile-launched simulator run (see :func:`samples_enabled`), into ``DEBUG_SAMPLES_DIR``. Nothing
21
+ here sends an image anywhere.
22
+
23
+ Usage, from a tutorial's directory after ``make run``::
24
+
25
+ python ../dev/plot_utils.py # reads /tmp/nvflare/<this tutorial>/flip_fedavg
26
+ python ../dev/plot_utils.py --workspace <dir> --out-dir <dir>
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import logging
33
+ import os
34
+ import re
35
+ from pathlib import Path
36
+
37
+ import numpy as np
38
+ import torch
39
+ from flip.constants import FlipConstants
40
+ from torchvision.utils import make_grid, save_image
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ #: Set by the tutorial Makefiles' ``sim`` recipe only: where the images go, and half of the simulator
45
+ #: check in :func:`in_simulation`. Never a ``config.json`` key.
46
+ DEBUG_DIR_ENV = "DEBUG_SAMPLES_DIR"
47
+
48
+ #: How many images to write per grid when ``DEBUG_SAMPLES_MAX`` is absent from the config.
49
+ DEFAULT_MAX_IMAGES = 8
50
+
51
+ #: Training iterations between tri-planar figures when ``DEBUG_PLOT_EVERY`` is absent.
52
+ DEFAULT_PLOT_EVERY = 100
53
+
54
+ #: The three orthogonal views: display name, the array axis each one holds fixed, and whether the
55
+ #: slice needs a left-right flip after the shared rotation.
56
+ #:
57
+ #: Volumes reach here as ``(C, X, Y, Z)`` with the chain's ``Orientationd(axcodes="RAS")``
58
+ #: guaranteeing what the axes mean: +X is right, +Y anterior, +Z superior. So a sagittal view fixes
59
+ #: X, a coronal view fixes Y, and an axial view fixes Z. Without that guarantee these labels — and
60
+ #: the flip below — would be guesses.
61
+ #:
62
+ #: One ``np.rot90`` puts the vertical axis the right way up in all three (superior up for sagittal
63
+ #: and coronal, anterior up for axial), but it does not settle the horizontal one, and the sagittal
64
+ #: view is the odd one out. Coronal and axial both have **X** across the page, so rot90 leaves them
65
+ #: with the patient's left on the viewer's left — neurological convention. Sagittal has **Y** across
66
+ #: the page instead, which leaves anterior on the *right*: the head faces backwards compared with how
67
+ #: every other view here, and every standard neuroimaging viewer, shows it. Hence the flip.
68
+ _PLANES = (("sagittal", 0, True), ("coronal", 1, False), ("axial", 2, False))
69
+
70
+
71
+ def in_simulation() -> bool:
72
+ """Whether this process is a local simulator run launched by a tutorial Makefile.
73
+
74
+ Needs **both** signals, so that neither alone can open the gate on a trust:
75
+
76
+ - ``FlipConstants.LOCAL_DEV`` is true. Every trust deployment (compose and Helm) sets it to
77
+ ``false``, and the ``flip`` library already reads its data from the trust APIs on that value,
78
+ so a client with it true cannot be running a real cohort. It is not enough on its own because
79
+ it *defaults* to true when the variable is unset.
80
+ - ``DEBUG_SAMPLES_DIR`` is set and non-blank. Only the tutorial Makefiles' ``sim`` recipe sets
81
+ it; it is not a ``config.json`` key, so nothing uploaded with the app can supply it.
82
+
83
+ Returns:
84
+ bool: True only inside a Makefile-launched simulator run.
85
+ """
86
+ if not os.environ.get(DEBUG_DIR_ENV, "").strip():
87
+ return False
88
+ try:
89
+ return FlipConstants.LOCAL_DEV is True
90
+ except Exception: # unreadable settings: fail closed
91
+ return False
92
+
93
+
94
+ def samples_enabled(config: dict) -> bool:
95
+ """Whether this job writes client-local debug images.
96
+
97
+ True only when ``config.json`` sets ``SAVE_DEBUG_SAMPLES`` **and** :func:`in_simulation` holds.
98
+ The config is the user's switch — false means no images, simulator included. It is never what
99
+ separates simulation from production, because ``config.json`` travels with the app to the trust:
100
+ in production (``LOCAL_DEV=false``, no ``DEBUG_SAMPLES_DIR``) this returns False whatever the
101
+ config says, so patient-derived pixels are never written at a trust.
102
+
103
+ This is the single gate for both writers. ``save_grid`` and ``save_triplanar`` consult it and
104
+ no-op when it is false, so a caller must never add a second condition of its own: a sampling
105
+ block that runs on a looser test than this one pays for its output and then discards it.
106
+
107
+ Args:
108
+ config (dict): The parsed ``config.json``.
109
+
110
+ Returns:
111
+ bool: True when ``SAVE_DEBUG_SAMPLES`` is truthy and this is a simulator run.
112
+ """
113
+ return bool(config.get("SAVE_DEBUG_SAMPLES", False)) and in_simulation()
114
+
115
+
116
+ def plot_every(config: dict) -> int:
117
+ """Iterations between tri-planar figures, from ``DEBUG_PLOT_EVERY`` (default 100).
118
+
119
+ Args:
120
+ config (dict): The parsed ``config.json``.
121
+
122
+ Returns:
123
+ int: The interval. Zero or negative disables the figures while leaving the grids on.
124
+ """
125
+ return int(config.get("DEBUG_PLOT_EVERY", DEFAULT_PLOT_EVERY))
126
+
127
+
128
+ def due_for_plot(iteration: int, config: dict) -> bool:
129
+ """Whether ``iteration`` (0-based) is one of the every-N steps that gets a tri-planar figure.
130
+
131
+ True on iteration 0 as well as every Nth after it, so a run that crashes early still leaves one
132
+ figure behind — the first is also the most diagnostic, being the only look at an untrained model.
133
+
134
+ Args:
135
+ iteration (int): Zero-based training step, counted across rounds and epochs (not within one
136
+ epoch — an epoch shorter than the interval would otherwise fire once per epoch).
137
+ config (dict): The parsed ``config.json``.
138
+
139
+ Returns:
140
+ bool: True when a figure is due and debug output is enabled at all.
141
+ """
142
+ if not samples_enabled(config):
143
+ return False
144
+ interval = plot_every(config)
145
+ return interval > 0 and iteration % interval == 0
146
+
147
+
148
+ def debug_dir() -> Path:
149
+ """``DEBUG_SAMPLES_DIR``, created if needed; the tutorial Makefiles point it under fl-tutorials/data/."""
150
+ target = Path(os.environ[DEBUG_DIR_ENV].strip()).expanduser().resolve()
151
+ target.mkdir(parents=True, exist_ok=True)
152
+ return target
153
+
154
+
155
+ def _to_2d(batch: torch.Tensor) -> torch.Tensor:
156
+ """Reduce a batch to something ``make_grid`` can tile, taking a mid-slice of any volume.
157
+
158
+ Args:
159
+ batch (torch.Tensor): ``(B, C, H, W)`` or ``(B, C, H, W, D)``.
160
+
161
+ Returns:
162
+ torch.Tensor: ``(B, C, H, W)`` — volumes sliced at the centre of the last axis, which for the
163
+ RAS-oriented volumetric chain is the axial mid-slice.
164
+ """
165
+ if batch.ndim == 5:
166
+ return batch[..., batch.shape[-1] // 2]
167
+ return batch
168
+
169
+
170
+ def _name_parts(name: str, site_name: str, step: int | None) -> list[str]:
171
+ """Filename parts shared by both writers: what, where, when — in that order."""
172
+ parts = [name]
173
+ if site_name:
174
+ parts.append(site_name)
175
+ if step is not None:
176
+ parts.append(f"step{step:04d}")
177
+ return parts
178
+
179
+
180
+ def _prepare(batch: torch.Tensor, max_images: int) -> torch.Tensor:
181
+ """Detach, truncate and flatten a batch to greyscale 2-D images ready for tiling."""
182
+ prepared = _to_2d(batch.detach().float().cpu()[:max_images])
183
+ if prepared.shape[1] > 1:
184
+ # Multi-channel here means a latent, not a colour image; show the first channel rather than
185
+ # letting save_image interpret 3 channels as RGB.
186
+ prepared = prepared[:, :1]
187
+ return prepared
188
+
189
+
190
+ def save_grid(
191
+ tensors: dict[str, torch.Tensor],
192
+ name: str,
193
+ config: dict,
194
+ *,
195
+ site_name: str = "",
196
+ step: int | None = None,
197
+ ) -> Path | None:
198
+ """Write one PNG tiling the given batches, one row per entry. No-op unless enabled.
199
+
200
+ Rows are normalised **jointly**, not per image: independent scaling would rescale a washed-out
201
+ reconstruction back onto its input's range and hide precisely the intensity drift the comparison
202
+ is meant to expose.
203
+
204
+ Never raises. Debug output must not be able to fail a training round, so any error (a read-only
205
+ workspace, a tensor of an unexpected rank) is logged and swallowed.
206
+
207
+ Args:
208
+ tensors (dict[str, torch.Tensor]): Batches to tile, one row each, in insertion order — e.g.
209
+ ``{"input": images, "reconstruction": recon}``. Keys appear in the filename.
210
+ name (str): Short label for what this grid shows, e.g. ``"reconstruction"``.
211
+ config (dict): The parsed ``config.json``; read for ``SAVE_DEBUG_SAMPLES``/``DEBUG_SAMPLES_MAX``.
212
+ site_name (str): Client name, included in the filename so a shared workspace cannot collide.
213
+ step (int | None): Round or epoch counter, included in the filename to order the images.
214
+
215
+ Returns:
216
+ Path | None: The file written, or None when disabled or on any failure.
217
+ """
218
+ if not samples_enabled(config) or not tensors:
219
+ return None
220
+
221
+ try:
222
+ max_images = int(config.get("DEBUG_SAMPLES_MAX", DEFAULT_MAX_IMAGES))
223
+ rows = [_prepare(batch, max_images) for batch in tensors.values()]
224
+ columns = min(row.shape[0] for row in rows)
225
+ grid = make_grid(
226
+ torch.cat([row[:columns] for row in rows]),
227
+ nrow=columns,
228
+ normalize=True,
229
+ scale_each=False,
230
+ )
231
+
232
+ label = "_".join([name, *tensors.keys()]) if len(tensors) > 1 else name
233
+ path = debug_dir() / ("_".join(_name_parts(label, site_name, step)) + ".png")
234
+
235
+ save_image(grid, str(path))
236
+ logger.info(f"[DEBUG] Wrote {columns} sample(s) to {path}")
237
+ return path
238
+ except Exception as err:
239
+ logger.warning(f"[DEBUG] Could not write debug samples {name!r}: {err}")
240
+ return None
241
+
242
+
243
+ def _central_slice(volume: torch.Tensor, axis: int, flip_lr: bool = False) -> np.ndarray:
244
+ """The central slice of one 3-D volume along ``axis``, oriented for display.
245
+
246
+ Args:
247
+ volume (torch.Tensor): A single volume, ``(X, Y, Z)``, channel already removed.
248
+ axis (int): Spatial axis to hold fixed — 0 sagittal, 1 coronal, 2 axial.
249
+ flip_lr (bool): Mirror the slice horizontally after the rotation. True for the sagittal
250
+ view only; see :data:`_PLANES` for why it alone needs it.
251
+
252
+ Returns:
253
+ np.ndarray: The 2-D slice, rotated so the axis that runs superior (or anterior, for the
254
+ axial view) points up the page, and mirrored if this view needs it.
255
+ """
256
+ plane = volume.index_select(axis, torch.tensor([volume.shape[axis] // 2], device=volume.device)).squeeze(axis)
257
+ rotated = np.rot90(plane.numpy())
258
+ return np.fliplr(rotated) if flip_lr else rotated
259
+
260
+
261
+ def save_triplanar(
262
+ volumes: dict[str, torch.Tensor],
263
+ name: str,
264
+ config: dict,
265
+ *,
266
+ site_name: str = "",
267
+ step: int | None = None,
268
+ sample: int = 0,
269
+ ) -> Path | None:
270
+ """Write a 3x2 tri-planar figure: one row per view, one column per batch. No-op unless enabled.
271
+
272
+ Rows are sagittal / coronal / axial central cuts; columns are the entries of ``volumes`` in
273
+ insertion order — so ``{"input": images, "reconstruction": recon}`` puts the input beside what
274
+ the model made of it, in the same three planes.
275
+
276
+ This is the figure that answers questions a mid-slice grid cannot. An autoencoder can look
277
+ plausible on one axial slice while having learnt nothing about through-plane structure, and that
278
+ shows up immediately in the sagittal and coronal views.
279
+
280
+ **Each panel is scaled to its own range**, unlike :func:`save_grid`, which normalises jointly.
281
+ The trade-off is deliberate and goes the other way here: a joint scale is the honest one for
282
+ spotting intensity drift, but an early reconstruction spans a far wider range than the input
283
+ (noise, including negatives), so a shared scale compresses the input to flat grey and neither
284
+ column can be read. These figures exist to show *structure* — is there a brain, are the
285
+ ventricles in the right place, does the tumour survive — and structure needs per-panel contrast.
286
+ Read absolute intensity off the grids, not off these.
287
+
288
+ Never raises: debug output must not be able to fail a training round.
289
+
290
+ Args:
291
+ volumes (dict[str, torch.Tensor]): Batches to compare, ``(B, C, X, Y, Z)``, one column each.
292
+ name (str): Short label for what this figure shows, used in the filename.
293
+ config (dict): The parsed ``config.json``.
294
+ site_name (str): Client name, included in the filename so a shared workspace cannot collide.
295
+ step (int | None): Iteration or epoch counter, included in the filename to order the figures.
296
+ sample (int): Which item of the batch to show.
297
+
298
+ Returns:
299
+ Path | None: The file written, or None when disabled, when the tensors are not volumetric,
300
+ or on any failure.
301
+ """
302
+ if not samples_enabled(config) or not volumes:
303
+ return None
304
+
305
+ try:
306
+ import matplotlib
307
+
308
+ # Agg before pyplot: an FL client has no display, and the default backend would try to pick
309
+ # an interactive one and fail at import inside a training round.
310
+ matplotlib.use("Agg")
311
+ import matplotlib.pyplot as plt
312
+
313
+ picked = {}
314
+ for label, batch in volumes.items():
315
+ if batch.ndim != 5:
316
+ logger.info(f"[DEBUG] {name!r}: {label} is not volumetric ({tuple(batch.shape)}); skipping figure.")
317
+ return None
318
+ picked[label] = batch.detach().float().cpu()[min(sample, batch.shape[0] - 1), 0]
319
+
320
+ figure, axes = plt.subplots(len(_PLANES), len(picked), figsize=(3.2 * len(picked), 3.2 * len(_PLANES)))
321
+ axes = np.atleast_2d(axes).reshape(len(_PLANES), len(picked))
322
+ figure.patch.set_facecolor("black")
323
+
324
+ for row, (view, axis, flip_lr) in enumerate(_PLANES):
325
+ for column, (label, volume) in enumerate(picked.items()):
326
+ axis_handle = axes[row][column]
327
+ # No vmin/vmax: matplotlib scales each panel to its own min/max, which is the point
328
+ # (see the note on contrast above). A flat panel is left to matplotlib, which
329
+ # renders a constant array as mid-grey rather than dividing by a zero range.
330
+ axis_handle.imshow(_central_slice(volume, axis, flip_lr), cmap="gray")
331
+ axis_handle.set_facecolor("black")
332
+ axis_handle.axis("off")
333
+ if row == 0:
334
+ axis_handle.set_title(label, color="0.85", fontsize=11)
335
+ if column == 0:
336
+ # A y-label survives axis("off"), which is why the view name rides here rather
337
+ # than in a title that would collide with the column headings.
338
+ axis_handle.set_ylabel(view, color="0.85", fontsize=10)
339
+ axis_handle.axis("on")
340
+ axis_handle.set_xticks([])
341
+ axis_handle.set_yticks([])
342
+ for spine in axis_handle.spines.values():
343
+ spine.set_visible(False)
344
+
345
+ figure.tight_layout()
346
+ path = debug_dir() / ("_".join(_name_parts(name, site_name, step)) + ".png")
347
+ figure.savefig(path, facecolor=figure.get_facecolor(), dpi=110)
348
+ plt.close(figure)
349
+ logger.info(f"[DEBUG] Wrote tri-planar figure to {path}")
350
+ return path
351
+ except Exception as err:
352
+ logger.warning(f"[DEBUG] Could not write tri-planar figure {name!r}: {err}")
353
+ return None
354
+
355
+
356
+ # --------------------------------------------------------------------------------------------
357
+ # Analysis half: loss curves from a simulator log. Host-side only — see the module docstring.
358
+ # --------------------------------------------------------------------------------------------
359
+
360
+ #: Log lines arrive as ``<timestamp> - <logger> - INFO - <message>``. Splitting the message off is
361
+ #: what stops the timestamp's own colons ("20:10:04") being read as data. A line that does *not*
362
+ #: match is a continuation of the message above it — the per-epoch summary is logged as one
363
+ #: multi-line string, so its numbers live on unprefixed lines — and is parsed whole.
364
+ _MESSAGE_RE = re.compile(r" - (?:DEBUG|INFO|WARNING|ERROR|CRITICAL) - (?P<message>.*)$")
365
+
366
+ #: One ``name: number`` pair inside a message. Deliberately generic: every tutorial here logs its
367
+ #: losses in that shape but with different names, so hard-coding one tutorial's names would leave
368
+ #: the other two with empty plots. The name charset excludes quotes,
369
+ #: commas and hyphens, which is what keeps a name from bridging across ``a: 1, b: 2`` or reaching
370
+ #: back into the logger prefix; ``nan``/``inf`` are accepted as values so a run that broke still
371
+ #: plots up to the point it broke.
372
+ _PAIR_RE = re.compile(r"(?P<name>[A-Za-z][\w ()]*?)\s*:\s*(?P<value>[-+]?(?:\d+\.?\d*(?:[eE][-+]?\d+)?|nan|inf))\b")
373
+
374
+ #: Fixed colours for the names this tree logs today, so two sites' figures can be read side by side
375
+ #: and successive runs stay comparable. Anything unrecognised is assigned from _PALETTE in sorted
376
+ #: order, which is deterministic for a given set of names.
377
+ COLOURS = {
378
+ "l1": "#4ea1ff",
379
+ "perceptual": "#ffd166",
380
+ "gan": "#ff6b6b",
381
+ "KL": "#9b7bff",
382
+ "Total loss D": "#7bffb0",
383
+ "Total loss DM": "#4ea1ff",
384
+ "Validation loss (L1)": "#4ea1ff",
385
+ "Validation SSIM (foreground)": "#ff9f4e",
386
+ "Validation DM": "#ff9f4e",
387
+ # The per-epoch aggregates, coloured to match the per-iteration term each one totals.
388
+ "L1": "#4ea1ff",
389
+ "Perceptual": "#ffd166",
390
+ "GAN": "#ff6b6b",
391
+ "KLD": "#9b7bff",
392
+ "Total loss G": "#eeeeee",
393
+ }
394
+ _PALETTE = ("#4ea1ff", "#ffd166", "#ff6b6b", "#9b7bff", "#7bffb0", "#ff9f4e", "#59d4d4", "#d98cc4")
395
+
396
+ #: Smoothing for the per-iteration panel, which is unreadable raw at BATCH_SIZE 2.
397
+ EMA_ALPHA = 0.02
398
+
399
+ #: A series needs at least this many points to be worth an axis; it also filters out the stray
400
+ #: ``name: number`` pairs that appear once in a startup banner.
401
+ MIN_POINTS = 2
402
+
403
+ #: Series logged at the same cadence share a length. Anything within this fraction of the longest
404
+ #: series is treated as per-iteration; the rest is per-epoch. A tutorial that logs only per-epoch
405
+ #: (the two diffusion ones) therefore yields a single panel rather than an empty one.
406
+ #:
407
+ #: The same ratio is then applied *within* the per-epoch band, which is what keeps incidental
408
+ #: ``name: number`` pairs out of the figure. A log is full of them — ``localhost: 34953``,
409
+ #: ``PID: 1005143``, ``train size: 30`` — and they are not distinguishable from a metric by name or
410
+ #: by value. They are distinguishable by cadence: a metric is logged once per iteration or once per
411
+ #: epoch, so it appears as many times as there were iterations or epochs, while a startup line
412
+ #: appears once or twice however long the run goes on.
413
+ DENSE_RATIO = 0.9
414
+
415
+
416
+ def _ema(values: list[float], alpha: float = EMA_ALPHA) -> list[float]:
417
+ """Exponential moving average, skipping non-finite points rather than propagating them."""
418
+ out: list[float] = []
419
+ acc = next((v for v in values if v == v and abs(v) != float("inf")), 0.0)
420
+ for value in values:
421
+ if value == value and abs(value) != float("inf"):
422
+ acc = alpha * value + (1 - alpha) * acc
423
+ out.append(acc)
424
+ return out
425
+
426
+
427
+ def parse_log(path: Path) -> dict[str, list[float]]:
428
+ """Return every ``name: number`` series found in one site's ``log.txt``, in order of appearance.
429
+
430
+ Args:
431
+ path (Path): The site's ``log.txt``.
432
+
433
+ Returns:
434
+ dict[str, list[float]]: Series name to its values, longest series first.
435
+ """
436
+ series: dict[str, list[float]] = {}
437
+ with path.open(errors="replace") as handle:
438
+ for line in handle:
439
+ prefixed = _MESSAGE_RE.search(line)
440
+ message = prefixed.group("message") if prefixed else line
441
+ for match in _PAIR_RE.finditer(message):
442
+ series.setdefault(match.group("name"), []).append(float(match.group("value")))
443
+ return dict(sorted(series.items(), key=lambda item: -len(item[1])))
444
+
445
+
446
+ def split_by_cadence(series: dict[str, list[float]]) -> tuple[dict[str, list[float]], dict[str, list[float]]]:
447
+ """Split series into per-iteration and per-epoch groups by how often each was logged.
448
+
449
+ Args:
450
+ series (dict[str, list[float]]): Output of :func:`parse_log`.
451
+
452
+ Returns:
453
+ tuple: ``(dense, sparse)``; ``sparse`` is empty when everything shares one cadence.
454
+ """
455
+ usable = {name: values for name, values in series.items() if len(values) >= MIN_POINTS}
456
+ if not usable:
457
+ return {}, {}
458
+ longest = max(len(values) for values in usable.values())
459
+ dense = {n: v for n, v in usable.items() if len(v) >= longest * DENSE_RATIO}
460
+
461
+ remainder = {n: v for n, v in usable.items() if len(v) < longest * DENSE_RATIO}
462
+ if not remainder:
463
+ return dense, {}
464
+ epoch_cadence = max(len(values) for values in remainder.values())
465
+ sparse = {n: v for n, v in remainder.items() if len(v) >= epoch_cadence * DENSE_RATIO}
466
+ return dense, sparse
467
+
468
+
469
+ def _colour_for(name: str, index: int) -> str:
470
+ return COLOURS.get(name, _PALETTE[index % len(_PALETTE)])
471
+
472
+
473
+ def _style(axis, title: str) -> None:
474
+ """Apply the shared dark styling to one axis."""
475
+ axis.set_facecolor("#111111")
476
+ axis.set_title(title, color="#eeeeee")
477
+ axis.tick_params(colors="#bbbbbb")
478
+ axis.grid(True, color="#333333", linewidth=0.5)
479
+ for spine in axis.spines.values():
480
+ spine.set_color("#444444")
481
+ axis.xaxis.label.set_color("#bbbbbb")
482
+ axis.yaxis.label.set_color("#bbbbbb")
483
+
484
+
485
+ def _legend(axis, handles=None, labels=None) -> None:
486
+ """Legend outside the axes on the right, so it cannot sit on top of the curves.
487
+
488
+ The generic parser finds however many series a tutorial logs — eight for the autoencoder's
489
+ per-epoch panel — which is more than fits inside the plot area.
490
+ """
491
+ axis.legend(
492
+ *([handles, labels] if handles is not None else []),
493
+ loc="upper left",
494
+ bbox_to_anchor=(1.12, 1.0),
495
+ fontsize=8,
496
+ facecolor="#1c1c1c",
497
+ edgecolor="#444444",
498
+ labelcolor="#eeeeee",
499
+ )
500
+
501
+
502
+ def plot_site(site: str, series: dict[str, list[float]], out_dir: Path) -> Path:
503
+ """Write the loss figure for one site and return its path.
504
+
505
+ The per-iteration panel is log-scaled when every value is positive: the terms routinely span two
506
+ decades, and on a linear axis the smallest of them — usually the one that is misweighted, which
507
+ is exactly what you are looking for — is a flat line on the floor.
508
+
509
+ Args:
510
+ site (str): Site name, used in the title and filename.
511
+ series (dict[str, list[float]]): Output of :func:`parse_log`.
512
+ out_dir (Path): Directory to write into; created if absent.
513
+
514
+ Returns:
515
+ Path: The figure written.
516
+ """
517
+ import matplotlib
518
+
519
+ matplotlib.use("Agg")
520
+ import matplotlib.pyplot as plt
521
+
522
+ dense, sparse = split_by_cadence(series)
523
+ panels = [p for p in (dense, sparse) if p]
524
+ figure, axes = plt.subplots(len(panels), 1, figsize=(13, 4 * len(panels)), facecolor="#111111", squeeze=False)
525
+
526
+ for axis, panel in zip(axes.flatten(), panels, strict=True):
527
+ is_dense = panel is dense
528
+ # SSIM is bounded [0, 1] where the losses are not, so it gets its own axis or it flattens
529
+ # every other series against the top of the plot.
530
+ twin_names = [n for n in panel if "SSIM" in n] if not is_dense else []
531
+ twin = axis.twinx() if twin_names else None
532
+
533
+ for index, (name, values) in enumerate(panel.items()):
534
+ colour = _colour_for(name, index)
535
+ target = twin if name in twin_names else axis
536
+ if is_dense:
537
+ target.plot(values, color=colour, alpha=0.15, linewidth=0.6)
538
+ target.plot(_ema(values), color=colour, linewidth=1.8, label=name)
539
+ else:
540
+ steps = range(1, len(values) + 1)
541
+ target.plot(steps, values, color=colour, marker="o", markersize=3, label=name)
542
+
543
+ # Log y whenever everything is positive. The terms routinely span two or three decades, and
544
+ # on a linear axis the smallest of them — usually the misweighted one, which is exactly what
545
+ # you are looking for — is a flat line on the floor. On the per-epoch panel the first epoch
546
+ # is several times any later one, so a linear axis spends most of its height on it.
547
+ plotted = [values for name, values in panel.items() if name not in twin_names]
548
+ finite = [v for values in plotted for v in values if v == v and abs(v) != float("inf")]
549
+ if finite and min(finite) > 0:
550
+ axis.set_yscale("log")
551
+ axis.set_xlabel("training iteration" if is_dense else "local epoch")
552
+ axis.set_ylabel("weighted contribution to $\\mathcal{L}_G$" if is_dense else "loss")
553
+ _style(axis, f"{site} — {'per-iteration loss terms' if is_dense else 'per-epoch summary'}")
554
+
555
+ if twin is not None:
556
+ twin.set_ylabel(" / ".join(twin_names), color=COLOURS.get(twin_names[0], _PALETTE[5]))
557
+ twin.tick_params(axis="y", colors=COLOURS.get(twin_names[0], _PALETTE[5]))
558
+ for spine in twin.spines.values():
559
+ spine.set_color("#444444")
560
+ handles, labels = axis.get_legend_handles_labels()
561
+ extra_handles, extra_labels = twin.get_legend_handles_labels()
562
+ _legend(axis, handles + extra_handles, labels + extra_labels)
563
+ else:
564
+ _legend(axis)
565
+
566
+ figure.tight_layout()
567
+ out_dir.mkdir(parents=True, exist_ok=True)
568
+ destination = out_dir / f"losses_{site}.png"
569
+ figure.savefig(destination, dpi=110, facecolor=figure.get_facecolor())
570
+ plt.close(figure)
571
+ return destination
572
+
573
+
574
+ def main() -> None:
575
+ """Plot every site's losses from a simulator workspace. Host-side entry point."""
576
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
577
+ parser.add_argument(
578
+ "--workspace",
579
+ type=Path,
580
+ default=Path("/tmp/nvflare") / Path.cwd().name / "flip_fedavg",
581
+ help="simulator workspace root (default: this tutorial's, from the current directory)",
582
+ )
583
+ parser.add_argument("--out-dir", type=Path, default=None, help="defaults to $DEBUG_SAMPLES_DIR, else ./")
584
+ args = parser.parse_args()
585
+
586
+ out_dir = args.out_dir or Path(os.environ.get(DEBUG_DIR_ENV, "."))
587
+
588
+ logs = sorted(args.workspace.glob("site-*/log.txt"))
589
+ if not logs:
590
+ raise SystemExit(f"no site logs under {args.workspace} — pass --workspace")
591
+
592
+ for log in logs:
593
+ site = log.parent.name
594
+ series = parse_log(log)
595
+ dense, sparse = split_by_cadence(series)
596
+ if not dense and not sparse:
597
+ print(f"{site}: no scalar lines yet, skipping")
598
+ continue
599
+ destination = plot_site(site, series, out_dir)
600
+ counts = ", ".join(f"{name} x{len(values)}" for name, values in list(dense.items())[:4])
601
+ print(f"{site}: {counts} -> {destination}")
602
+
603
+
604
+ if __name__ == "__main__":
605
+ main()
code/trainer.py ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ # http://www.apache.org/licenses/LICENSE-2.0
6
+ # Unless required by applicable law or agreed to in writing, software
7
+ # distributed under the License is distributed on an "AS IS" BASIS,
8
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9
+ # See the License for the specific language governing permissions and
10
+ # limitations under the License.
11
+
12
+ """FLARE Client API script for the FLIP pixel-space diffusion tutorial.
13
+
14
+ Trains a denoising diffusion model (MONAI's ``DiffusionModelUNet``) to generate 2-D chest X-rays, as
15
+ an ordinary FedAvg job. On a ``train`` task the client trains the received model for ``LOCAL_ROUNDS``
16
+ epochs and sends back the weight difference; on an ``evaluate`` task it scores the aggregated model
17
+ on its held-out split.
18
+
19
+ Training noises each image at a random timestep and teaches the UNet to predict that noise (MSE).
20
+ Every ``VALIDATE_EVERY`` epochs the same loss is scored on the held-out split and, in local runs,
21
+ sample images are drawn. Training runs in full precision.
22
+ """
23
+
24
+ import argparse
25
+ import json
26
+ import logging
27
+ import math
28
+ import os
29
+ from pathlib import Path
30
+
31
+ import numpy as np
32
+ import nvflare.client as flare
33
+ import pandas as pd
34
+ import pydicom
35
+ import torch
36
+ from flip import FLIP
37
+ from flip.constants import ResourceType
38
+ from models import get_model
39
+ from monai.data import DataLoader, Dataset
40
+ from monai.inferers import DiffusionInferer
41
+ from monai.networks.schedulers import DDPMScheduler
42
+ from nvflare.client.tracking import SummaryWriter
43
+ from transforms import get_xray_transforms
44
+
45
+ # plot_utils.py is a local-development helper (../dev/): job.py stages it into simulator runs, and it is
46
+ # never uploaded, so on a trust this import fails and debug output is simply off.
47
+ try:
48
+ from plot_utils import samples_enabled, save_grid
49
+ except ModuleNotFoundError as err:
50
+ if err.name != "plot_utils":
51
+ raise
52
+
53
+ def samples_enabled(config: dict) -> bool:
54
+ return False
55
+
56
+ def save_grid(*args: object, **kwargs: object) -> None:
57
+ return None
58
+
59
+
60
+ logger = logging.getLogger(__name__)
61
+
62
+ #: Epochs between in-loop validation and sampling when ``VALIDATE_EVERY`` is absent from the config.
63
+ DEFAULT_VALIDATE_EVERY = 10
64
+
65
+ #: Images drawn per sample grid.
66
+ NUM_DEBUG_SAMPLES = 4
67
+
68
+ #: Max gradient norm when ``GRAD_CLIP_NORM`` is absent from the config.
69
+ DEFAULT_GRAD_CLIP_NORM = 1.0
70
+
71
+
72
+ def parse_args() -> argparse.Namespace:
73
+ parser = argparse.ArgumentParser()
74
+ parser.add_argument("--project_id", type=str, default="")
75
+ return parser.parse_args()
76
+
77
+
78
+ def load_query() -> str:
79
+ """Read the cohort query from the client app config.
80
+
81
+ FLARE's TaskScriptRunner does a naive whitespace split on task_script_args, so the SQL query
82
+ (which can contain spaces) is plumbed via the top-level ``query`` key in
83
+ ``config/config_fed_client.json`` rather than as a CLI flag. In dev/simulator mode this is
84
+ ignored by ``flip.get_dataframe``.
85
+ """
86
+ client_cfg = Path(__file__).parent.parent / "config" / "config_fed_client.json"
87
+ if client_cfg.exists():
88
+ try:
89
+ return json.loads(client_cfg.read_text()).get("query", "")
90
+ except Exception:
91
+ return ""
92
+ return ""
93
+
94
+
95
+ def load_config() -> dict:
96
+ """Load the user-supplied config.json that sits next to this script."""
97
+ config_path = Path(__file__).parent.resolve() / "config.json"
98
+ with open(config_path) as f:
99
+ return json.load(f)
100
+
101
+
102
+ def validate_every(config: dict) -> int:
103
+ """Epochs between in-loop validation and sampling, from ``VALIDATE_EVERY``; zero or less turns it off.
104
+
105
+ The default is above 1 because the pass also samples, which runs the full reverse diffusion.
106
+ """
107
+ return int(config.get("VALIDATE_EVERY", DEFAULT_VALIDATE_EVERY))
108
+
109
+
110
+ def due_for_validation(epoch: int, epochs: int, config: dict) -> bool:
111
+ """Whether this 0-based epoch validates: every ``VALIDATE_EVERY`` epochs, and always the last."""
112
+ interval = validate_every(config)
113
+ if interval <= 0:
114
+ return False
115
+ return (epoch + 1) % interval == 0 or epoch == epochs - 1
116
+
117
+
118
+ def grad_clip_norm(config: dict) -> float:
119
+ """Max global gradient norm, from ``GRAD_CLIP_NORM``; zero or less turns clipping off."""
120
+ return float(config.get("GRAD_CLIP_NORM", DEFAULT_GRAD_CLIP_NORM))
121
+
122
+
123
+ def lr_at(epoch: int, config: dict) -> float:
124
+ """The learning rate at an epoch of the job: half a cosine from ``LR_START`` to ``LR_END``, then held.
125
+
126
+ The decay spans the job's first ``LR_DECAY_EPOCHS`` epochs, so ``epoch`` counts across rounds
127
+ (``global_round * LOCAL_ROUNDS + epoch``). Without ``LR_END`` or a positive ``LR_DECAY_EPOCHS`` the
128
+ rate stays at ``LR_START``.
129
+ """
130
+ start = float(config["LR_START"])
131
+ end = float(config.get("LR_END", start))
132
+ decay_epochs = int(config.get("LR_DECAY_EPOCHS", 0) or 0)
133
+ if decay_epochs <= 0:
134
+ return start
135
+ progress = min(epoch, decay_epochs) / decay_epochs
136
+ return end + (start - end) * 0.5 * (1.0 + math.cos(math.pi * progress))
137
+
138
+
139
+ def set_learning_rate(optimizer: torch.optim.Optimizer, lr: float) -> None:
140
+ for group in optimizer.param_groups:
141
+ group["lr"] = lr
142
+
143
+
144
+ def site_data_paths(site_name: str) -> tuple[Path, Path] | None:
145
+ """This simulated site's own ``(images_dir, dataframe)``, from ``SITE{N}_IMAGES_DIR`` / ``SITE{N}_DATAFRAME``.
146
+
147
+ Set only by the tutorial Makefile for a local run on per-site data ("site-1" reads ``SITE1_*``).
148
+ ``None`` when either is unset or blank: the site then reads the shared ``DEV_*`` dataset through
149
+ ``FLIP``, as it does on a trust.
150
+ """
151
+ prefix = site_name.replace("-", "").upper()
152
+ images_dir = os.environ.get(f"{prefix}_IMAGES_DIR", "").strip()
153
+ dataframe = os.environ.get(f"{prefix}_DATAFRAME", "").strip()
154
+ if not images_dir or not dataframe:
155
+ return None
156
+ return Path(images_dir), Path(dataframe)
157
+
158
+
159
+ def split_for_site(items: list, site_name: str) -> list:
160
+ """In the simulator, give "site-1" and "site-2" half the shared dataset each; on a trust, all of it."""
161
+ half = len(items) // 2
162
+ site = site_name.replace("-", "")
163
+ if site == "site1":
164
+ return items[:half]
165
+ if site == "site2":
166
+ return items[half:]
167
+ return items
168
+
169
+
170
+ def noise_prediction_loss(
171
+ model: torch.nn.Module, inferer: DiffusionInferer, scheduler: DDPMScheduler, images: torch.Tensor
172
+ ) -> torch.Tensor:
173
+ """The diffusion objective: noise each image at a random timestep and score the predicted noise."""
174
+ noise = torch.randn_like(images)
175
+ timesteps = torch.randint(0, scheduler.num_train_timesteps, (images.shape[0],), device=images.device)
176
+ noise_pred = inferer(inputs=images, diffusion_model=model.diffusion_model, noise=noise, timesteps=timesteps)
177
+ return torch.nn.functional.mse_loss(noise_pred.float(), noise.float())
178
+
179
+
180
+ def score_split(
181
+ model: torch.nn.Module,
182
+ loader: DataLoader,
183
+ inferer: DiffusionInferer,
184
+ scheduler: DDPMScheduler,
185
+ device: torch.device,
186
+ ) -> float:
187
+ """Mean noise-prediction loss over a split, in eval mode; restores the caller's mode afterwards."""
188
+ was_training = model.diffusion_model.training
189
+ model.diffusion_model.eval()
190
+ losses = []
191
+ try:
192
+ with torch.no_grad():
193
+ for batch in loader:
194
+ losses.append(noise_prediction_loss(model, inferer, scheduler, batch["image"].to(device)).item())
195
+ finally:
196
+ model.diffusion_model.train(was_training)
197
+ return float(np.mean(losses)) if losses else float("nan")
198
+
199
+
200
+ def sample_and_save(
201
+ model: torch.nn.Module,
202
+ inferer: DiffusionInferer,
203
+ scheduler: DDPMScheduler,
204
+ config: dict,
205
+ device: torch.device,
206
+ *,
207
+ step: int | None = None,
208
+ ) -> None:
209
+ """Generate ``NUM_DEBUG_SAMPLES`` images and save them as one grid, in local runs only.
210
+
211
+ Samples are the honest read on a diffusion model: the loss barely moves between a model that
212
+ generates radiographs and one that generates texture.
213
+ """
214
+ if not samples_enabled(config):
215
+ return
216
+ shape = [NUM_DEBUG_SAMPLES, config["net_config"]["diffusion_model"]["in_channels"], *config["spatial_shape"]]
217
+ was_training = model.diffusion_model.training
218
+ model.diffusion_model.eval()
219
+ try:
220
+ with torch.no_grad():
221
+ samples = inferer.sample(
222
+ input_noise=torch.randn(shape, device=device),
223
+ diffusion_model=model.diffusion_model,
224
+ scheduler=scheduler,
225
+ save_intermediates=False,
226
+ # No progress bar: simulated sites share one terminal and would overwrite each other's.
227
+ verbose=False,
228
+ )
229
+ finally:
230
+ model.diffusion_model.train(was_training)
231
+ save_grid({"sample": samples}, "samples", config, site_name=flare.get_site_name(), step=step)
232
+
233
+
234
+ class DiffusionTrainer:
235
+ """The model, optimiser, schedules and data for pixel-space diffusion training.
236
+
237
+ One instance lives for the whole ``flare.is_running()`` loop, so the optimiser state carries over
238
+ from round to round.
239
+ """
240
+
241
+ def __init__(self, config: dict, project_id: str, query: str):
242
+ self.config = config
243
+ self.project_id = project_id
244
+ self.epochs = config["LOCAL_ROUNDS"]
245
+
246
+ self.model = get_model()
247
+ self.device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
248
+ self.optimizer = torch.optim.Adam(
249
+ self.model.diffusion_model.parameters(), lr=config["LR_START"], weight_decay=1e-6, amsgrad=True
250
+ )
251
+ self.noise_scheduler = DDPMScheduler(
252
+ num_train_timesteps=1000,
253
+ schedule="scaled_linear_beta",
254
+ clip_sample=False,
255
+ prediction_type="epsilon",
256
+ beta_start=0.0015,
257
+ beta_end=0.0195,
258
+ )
259
+ self.inferer = DiffusionInferer(scheduler=self.noise_scheduler)
260
+
261
+ # Data
262
+ self.flip = FLIP()
263
+ site_name = flare.get_site_name()
264
+ site_paths = site_data_paths(site_name)
265
+ if site_paths is not None:
266
+ # Local run on per-site data: this site's own folder and CSV, used whole.
267
+ self.images_dir, dataframe_path = site_paths
268
+ dataframe = pd.read_csv(dataframe_path, sep=None, engine="python")
269
+ logger.info(f"Reading {dataframe_path} and {self.images_dir}")
270
+ else:
271
+ self.images_dir = None
272
+ dataframe = self.flip.get_dataframe(project_id=project_id, query=query)
273
+ train_items, val_items = self.build_datalist(dataframe)
274
+ if site_paths is None:
275
+ train_items = split_for_site(train_items, site_name)
276
+
277
+ workers = int(config.get("DATALOADER_WORKERS", 4)) # as in Ark+ (FLIP#1434)
278
+ self.train_loader = DataLoader(
279
+ Dataset(train_items, transform=get_xray_transforms()),
280
+ batch_size=config["BATCH_SIZE"],
281
+ shuffle=True,
282
+ num_workers=workers,
283
+ )
284
+ self.val_loader = DataLoader(
285
+ Dataset(val_items, transform=get_xray_transforms(is_validation=True)),
286
+ batch_size=config["BATCH_SIZE"],
287
+ shuffle=False,
288
+ num_workers=workers,
289
+ )
290
+
291
+ def build_datalist(self, dataframe: pd.DataFrame) -> tuple[list, list]:
292
+ """List each accession's readable DICOM images and split them into train and validation.
293
+
294
+ As ``xray_classification``'s ``build_datalist``, without the labels. Each header is parsed up
295
+ front, so an unreadable file is dropped here rather than failing a training step.
296
+ """
297
+ datalist: list[dict[str, str]] = []
298
+ for accession_id in dataframe["accession_id"]:
299
+ if self.images_dir is not None:
300
+ accession_folder_path = self.images_dir / str(accession_id)
301
+ else:
302
+ try:
303
+ accession_folder_path = self.flip.get_by_accession_number(
304
+ self.project_id, accession_id, resource_type=[ResourceType.DICOM]
305
+ )
306
+ except Exception as err:
307
+ logger.info(f"Could not get image data folder path for {accession_id}: {err}")
308
+ continue
309
+
310
+ for image in sorted(accession_folder_path.rglob("*.dcm")):
311
+ try:
312
+ pydicom.dcmread(str(image), stop_before_pixels=True)
313
+ except Exception as err:
314
+ logger.warning(f"Skipping invalid DICOM {image.name}: {err}")
315
+ continue
316
+ datalist.append({"image": str(image)})
317
+
318
+ logger.info(f"Found {len(datalist)} files in total.")
319
+ val_size = int(self.config["VAL_SPLIT"] * len(datalist))
320
+ return datalist[val_size:], datalist[:val_size]
321
+
322
+ def load_weights(self, weights: dict[str, torch.Tensor]) -> None:
323
+ self.model.load_state_dict(state_dict=weights)
324
+ self.model.diffusion_model.to(self.device)
325
+
326
+ def train_one_epoch(self) -> float:
327
+ """One pass over the training split; returns the mean loss."""
328
+ self.model.diffusion_model.train()
329
+ max_norm = grad_clip_norm(self.config)
330
+ total = 0.0
331
+ for batch in self.train_loader:
332
+ self.optimizer.zero_grad(set_to_none=True)
333
+ loss = noise_prediction_loss(self.model, self.inferer, self.noise_scheduler, batch["image"].to(self.device))
334
+ if not torch.isfinite(loss):
335
+ raise RuntimeError("Non-finite loss during training; stopping this site.")
336
+ loss.backward()
337
+ if max_norm > 0:
338
+ torch.nn.utils.clip_grad_norm_(self.model.diffusion_model.parameters(), max_norm)
339
+ self.optimizer.step()
340
+ total += loss.item()
341
+ return total / max(1, len(self.train_loader))
342
+
343
+ def train(self, writer: SummaryWriter, global_round: int) -> int:
344
+ """One ``train`` task: ``LOCAL_ROUNDS`` epochs. Returns the number of optimiser steps."""
345
+ for epoch in range(self.epochs):
346
+ step = global_round * self.epochs + epoch + 1
347
+ lr = lr_at(step - 1, self.config)
348
+ set_learning_rate(self.optimizer, lr)
349
+ epoch_loss = self.train_one_epoch()
350
+ logger.info(f"Epoch {epoch + 1} / {self.epochs}: loss {epoch_loss}")
351
+ writer.add_scalar("Total loss DM@epoch", epoch_loss, global_step=step)
352
+ writer.add_scalar("LR DM@epoch", lr, global_step=step)
353
+
354
+ if due_for_validation(epoch, self.epochs, self.config):
355
+ val_loss = score_split(self.model, self.val_loader, self.inferer, self.noise_scheduler, self.device)
356
+ logger.info(f"Validation loss: {val_loss}")
357
+ writer.add_scalar("Validation loss DM@epoch", val_loss, global_step=step)
358
+ sample_and_save(self.model, self.inferer, self.noise_scheduler, self.config, self.device, step=step)
359
+
360
+ return self.epochs * len(self.train_loader)
361
+
362
+ def validate(self, writer: SummaryWriter) -> float:
363
+ """One ``evaluate`` task: score the aggregated model on this site's held-out split, and sample."""
364
+ val_loss = score_split(self.model, self.val_loader, self.inferer, self.noise_scheduler, self.device)
365
+ sample_and_save(self.model, self.inferer, self.noise_scheduler, self.config, self.device)
366
+ writer.add_scalar("Total loss DM (val)", val_loss, global_step=0)
367
+ return val_loss
368
+
369
+
370
+ def to_torch_weights(input_model: flare.FLModel) -> dict[str, torch.Tensor]:
371
+ return {k: torch.as_tensor(v) for k, v in input_model.params.items()}
372
+
373
+
374
+ def send_weight_diff(original_params: dict, model: torch.nn.Module, n_iterations: int) -> None:
375
+ """Send the full-model weight diff for a completed training round.
376
+
377
+ Built one tensor at a time to avoid holding a second full copy of the model in RAM (mirrors
378
+ ``flip.utils.get_model_weights_diff``).
379
+ """
380
+ diff = {}
381
+ for k, v in model.state_dict().items():
382
+ new_arr = v.detach().cpu().numpy()
383
+ diff[k] = new_arr - np.asarray(original_params[k])
384
+ del new_arr
385
+
386
+ flare.send(
387
+ flare.FLModel(
388
+ params=diff,
389
+ params_type="DIFF",
390
+ meta={"NUM_STEPS_CURRENT_ROUND": n_iterations},
391
+ )
392
+ )
393
+
394
+
395
+ def main() -> None:
396
+ logging.basicConfig(level=logging.INFO)
397
+ args = parse_args()
398
+ config = load_config()
399
+
400
+ flare.init()
401
+ writer = SummaryWriter()
402
+
403
+ trainer = DiffusionTrainer(config, project_id=args.project_id, query=load_query())
404
+
405
+ while flare.is_running():
406
+ input_model = flare.receive()
407
+ if input_model is None:
408
+ break
409
+
410
+ global_round = input_model.current_round or 0
411
+
412
+ if flare.is_train():
413
+ logger.info(f"[diffusion trainer] received train task (round {global_round})")
414
+ trainer.load_weights(to_torch_weights(input_model))
415
+ n_iterations = trainer.train(writer, global_round)
416
+ send_weight_diff(input_model.params, trainer.model, n_iterations)
417
+
418
+ elif flare.is_evaluate():
419
+ trainer.load_weights(to_torch_weights(input_model))
420
+ val_loss = trainer.validate(writer)
421
+ logger.info(f"Validating the aggregated diffusion model on {flare.get_site_name()}'s data: {val_loss}")
422
+ flare.send(flare.FLModel(metrics={"val_loss": val_loss}))
423
+
424
+ else:
425
+ logger.warning("Received unknown task; ignoring.")
426
+
427
+
428
+ if __name__ == "__main__":
429
+ main()
code/transforms.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ # http://www.apache.org/licenses/LICENSE-2.0
6
+ # Unless required by applicable law or agreed to in writing, software
7
+ # distributed under the License is distributed on an "AS IS" BASIS,
8
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9
+ # See the License for the specific language governing permissions and
10
+ # limitations under the License.
11
+
12
+ """MONAI transforms for chest-X-ray inputs."""
13
+
14
+ import json
15
+ from pathlib import Path
16
+
17
+ import monai.transforms as mt
18
+
19
+
20
+ def load_spatial_shape() -> list[int]:
21
+ """Read ``spatial_shape`` from the ``config.json`` next to this file."""
22
+ with open(Path(__file__).parent / "config.json", "r") as f:
23
+ return list(json.load(f)["spatial_shape"])
24
+
25
+
26
+ SPATIAL_SHAPE = load_spatial_shape()
27
+
28
+
29
+ def to_grayscale(image):
30
+ """Average an ``(H, W, 3)`` RGB-stored radiograph to ``(H, W)``; grayscale passes through."""
31
+ if image.ndim == 3 and image.shape[-1] == 3:
32
+ return image.float().mean(dim=-1)
33
+ return image
34
+
35
+
36
+ def get_xray_transforms(is_validation: bool = False) -> mt.Compose:
37
+ """Return the MONAI transforms used for chest-X-ray training/validation.
38
+
39
+ Args:
40
+ is_validation (bool): When True, skip random augmentation.
41
+
42
+ Returns:
43
+ mt.Compose: Composed transform pipeline keyed on "image".
44
+ """
45
+ transforms = [
46
+ # A pinned reader: auto-detection picks ITKReader whenever itk is installed, which changes the
47
+ # axis order, and MONAI's default swap_ij=True transposes the radiograph, which no rotation or
48
+ # flip undoes (a "fix" mirrors it, silently swapping left and right). swap_ij=False keeps
49
+ # DICOM's (row, column) order; fl-tutorials/tests/ pins this for every app on this path.
50
+ mt.LoadImaged(keys=["image"], reader="PydicomReader", swap_ij=False),
51
+ mt.Lambdad(keys=["image"], func=to_grayscale),
52
+ mt.EnsureChannelFirstd(keys=["image"], channel_dim="no_channel"),
53
+ mt.Resized(keys=["image"], spatial_size=SPATIAL_SHAPE),
54
+ mt.ScaleIntensityd(keys=["image"]),
55
+ ]
56
+ if not is_validation:
57
+ transforms.append(mt.RandAffined(keys=["image"], rotate_range=[-0.05, 0.05], scale_range=[0.01, 0.05]))
58
+ return mt.Compose(transforms)
diffusion_model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5118f8e8a33cd94fb76a4966bd8f8cc9f5bca539aafcb18d5273b8a072d4c081
3
+ size 88359404
evaluation/sample_xray.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """4x4 grid of chest X-rays from the final global model of the X-ray diffusion tutorial.
2
+
3
+ The first n samples drawn from one seed, not picked, with the tutorial's own scheduler and sampler (the full
4
+ 1000-step DDPM), shown as the tutorial's debug grids are: one min-max over the whole grid. Runs on the CPU so it
5
+ leaves the GPU to the queue. Also saves the samples as a plain tensor.
6
+
7
+ python sample_xray.py <app custom dir> <FL_global_model.pt> <out.png> [n=16] [seed=0] [threads=8]
8
+ """
9
+
10
+ import json
11
+ import sys
12
+ import time
13
+ from pathlib import Path
14
+
15
+ import torch
16
+ from torchvision.utils import make_grid, save_image
17
+
18
+ app, ckpt, out = Path(sys.argv[1]), Path(sys.argv[2]), Path(sys.argv[3])
19
+ n, seed, threads = (int(sys.argv[i]) if len(sys.argv) > i else d for i, d in ((4, 16), (5, 0), (6, 8)))
20
+ sys.path.insert(0, str(app))
21
+ from models import get_model # noqa: E402
22
+ from monai.inferers import DiffusionInferer # noqa: E402
23
+ from monai.networks.schedulers import DDPMScheduler # noqa: E402
24
+
25
+ torch.set_num_threads(threads)
26
+ config = json.loads((app / "config.json").read_text())
27
+ model = get_model()
28
+ blob = torch.load(ckpt, map_location="cpu", weights_only=False) # our own run's output
29
+ model.load_state_dict(blob["model"] if isinstance(blob.get("model"), dict) else blob, strict=True)
30
+ model.eval()
31
+
32
+ scheduler = DDPMScheduler(num_train_timesteps=1000, schedule="scaled_linear_beta", clip_sample=False,
33
+ prediction_type="epsilon", beta_start=0.0015, beta_end=0.0195) # as trainer.py
34
+ inferer = DiffusionInferer(scheduler=scheduler)
35
+ shape = [n, config["net_config"]["diffusion_model"]["in_channels"], *config["spatial_shape"]]
36
+ generator = torch.Generator().manual_seed(seed)
37
+ start = time.time()
38
+ with torch.no_grad():
39
+ samples = inferer.sample(input_noise=torch.randn(shape, generator=generator), diffusion_model=model.diffusion_model,
40
+ scheduler=scheduler, save_intermediates=False, verbose=False)
41
+ print(f"sampled {n} in {time.time() - start:.0f} s; range {samples.min():.3f}..{samples.max():.3f}")
42
+ torch.save(samples.as_subclass(torch.Tensor).clone(), out.with_suffix(".pt"))
43
+ save_image(make_grid(samples, nrow=int(n**0.5), normalize=True, scale_each=False, padding=2), str(out))
44
+ print("wrote", out)
figures/samples_4x4.png ADDED

Git LFS Details

  • SHA256: e40436490d038e7c5e2f8b60f8ae9384905f3e02987de11460a6a2d21897d53d
  • Pointer size: 131 Bytes
  • Size of remote file: 213 kB
provenance.json ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "flip_commit": "b3a42840ab5a2d73e45b6d596bdea25f1b383502",
3
+ "install": "pip install \"flip-utils @ git+https://github.com/londonaicentre/FLIP@b3a42840ab5a2d73e45b6d596bdea25f1b383502#subdirectory=flip-utils\"",
4
+ "launch": "cd fl-tutorials/nvflare/image_synthesis/diffusion_model && python job.py --n_clients 2 --num_rounds 2",
5
+ "note": "Runs predate the tutorials' move to fl-tutorials/nvflare/image_generation/ (a2c053ec4).",
6
+ "sites": 2,
7
+ "hardware": "1 x NVIDIA GeForce RTX 5090 (both simulated sites)",
8
+ "versions": {
9
+ "torch": "2.13.0+cu130",
10
+ "monai": "1.6.0",
11
+ "nvflare": "2.9.0"
12
+ },
13
+ "wall_time": {
14
+ "start": "2026-10-22 21:33:39",
15
+ "end": "2026-10-23 02:38:35",
16
+ "hours_minutes": "5 h 05 min"
17
+ },
18
+ "model": "E6",
19
+ "local_epochs": 300,
20
+ "code_files": [
21
+ "config.json",
22
+ "models.py",
23
+ "plot_utils.py",
24
+ "trainer.py",
25
+ "transforms.py"
26
+ ],
27
+ "weights": {
28
+ "file": "diffusion_model.safetensors",
29
+ "sha256": "5118f8e8a33cd94fb76a4966bd8f8cc9f5bca539aafcb18d5273b8a072d4c081",
30
+ "dtype": "float32"
31
+ },
32
+ "final_validation_loss": {
33
+ "site-2": 0.0036,
34
+ "site-1": 0.0039
35
+ }
36
+ }