Skip to content

Train

hypertorch.train

ExperimentSharedLogger

Bases: Logger, ABC

Extends lightning.pytorch.loggers.Logger to provide a base class for experiment loggers that share state across instances in one process.

Source code in hypertorch/train/logger.py
class ExperimentSharedLogger(Logger, ABC):
    """
    Extends `lightning.pytorch.loggers.Logger` to provide a base class
    for experiment loggers that share state across instances in one process.
    """

    @abstractmethod
    def clear(self, experiment_name: str) -> None:
        """
        Clear the internal state of the logger for a specific experiment.

        Args:
            experiment_name: The name of the experiment to clear.
        """
        raise NotImplementedError("Subclasses must implement the clear method.")

    @abstractmethod
    def destroy(self) -> None:
        """
        Destroy the internal shared state of the logger.

        Caution: This method should be used with care, as it will clear all shared
            state across experiments in the current process. This means that any metrics
            or data logged by other experiments will be lost as well. Use this method only
            when you are certain that you want to clear all shared state, and not just the
            state for a specific experiment. In that case, use the `clear` methods instead.
        """
        raise NotImplementedError("Subclasses must implement the destroy method.")

clear(experiment_name) abstractmethod

Clear the internal state of the logger for a specific experiment.

Parameters:

Name Type Description Default
experiment_name str

The name of the experiment to clear.

required
Source code in hypertorch/train/logger.py
@abstractmethod
def clear(self, experiment_name: str) -> None:
    """
    Clear the internal state of the logger for a specific experiment.

    Args:
        experiment_name: The name of the experiment to clear.
    """
    raise NotImplementedError("Subclasses must implement the clear method.")

destroy() abstractmethod

Destroy the internal shared state of the logger.

This method should be used with care, as it will clear all shared

state across experiments in the current process. This means that any metrics or data logged by other experiments will be lost as well. Use this method only when you are certain that you want to clear all shared state, and not just the state for a specific experiment. In that case, use the clear methods instead.

Source code in hypertorch/train/logger.py
@abstractmethod
def destroy(self) -> None:
    """
    Destroy the internal shared state of the logger.

    Caution: This method should be used with care, as it will clear all shared
        state across experiments in the current process. This means that any metrics
        or data logged by other experiments will be lost as well. Use this method only
        when you are certain that you want to clear all shared state, and not just the
        state for a specific experiment. In that case, use the `clear` methods instead.
    """
    raise NotImplementedError("Subclasses must implement the destroy method.")

LaTexTableConfig

Bases: TypedDict

Configuration for the LaTex table logger.

Attributes:

Name Type Description
table_caption NotRequired[str]

Caption for the LaTex table.

sort_by NotRequired[list[str]]

Per-column sorting criteria ("asc" or "des").

border NotRequired[bool]

Whether to include borders in the LaTex table.

Source code in hypertorch/train/latex_logger.py
class LaTexTableConfig(TypedDict):
    """
    Configuration for the LaTex table logger.

    Attributes:
        table_caption: Caption for the LaTex table.
        sort_by: Per-column sorting criteria ("asc" or "des").
        border: Whether to include borders in the LaTex table.
    """

    table_caption: NotRequired[str]
    sort_by: NotRequired[list[str]]
    border: NotRequired[bool]

LaTexTableLogger

Bases: ExperimentSharedLogger

A Lightning Logger that accumulates metrics and writes a LaTex comparison table.

Multiple instances (one per model) share a class-level store keyed by experiment_name. Every time finalize() is called (after fit() or test() for each model), the current state of all accumulated metrics is written to a LaTex file. The last model to finalize produces the most complete table.

In distributed runs, only global rank zero accumulates metrics and writes tables. Metrics must therefore be synchronized before they reach the logger, for example by passing sync_dist=True to Lightning's metric logging methods.

This means the file is progressively updated as models finish training/testing, so you can open it mid-run to see partial results.

Source code in hypertorch/train/latex_logger.py
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
class LaTexTableLogger(ExperimentSharedLogger):
    """
    A Lightning Logger that accumulates metrics and writes a LaTex comparison table.

    Multiple instances (one per model) share a class-level store keyed by experiment_name.
    Every time finalize() is called (after fit() or test() for each model), the current
    state of all accumulated metrics is written to a LaTex file. The last model to
    finalize produces the most complete table.

    In distributed runs, only global rank zero accumulates metrics and writes tables.
    Metrics must therefore be synchronized before they reach the logger, for example by
    passing ``sync_dist=True`` to Lightning's metric logging methods.

    This means the file is progressively updated as models finish training/testing,
    so you can open it mid-run to see partial results.
    """

    # Class-level shared store: {experiment_name: {model_name: {metric_name: value}}}
    __shared_stores: ClassVar[dict[str, dict[str, dict[str, Any]]]] = {}

    def __init__(
        self,
        save_dir: str | Path,
        model_name: str,
        experiment_name: str,
        precision: int = 4,
        options: LaTexTableConfig | None = None,
    ) -> None:
        """
        Initialize the LaTex table logger.

        Args:
            save_dir: Base directory where comparison files are written.
            model_name: Full model name to use as the table row label.
            experiment_name: Shared key grouping models in the same experiment.
            precision: Decimal places for metric values.
            options: Optional table rendering configuration.
                Defaults to an empty dict configuration, if not provided.
        """
        super().__init__()
        validate_is_non_negative("precision", precision)

        self.__save_dir: str | Path = save_dir
        self.__model_name: str = model_name
        self.__experiment_name: str = experiment_name
        self.__precision: int = precision

        default_empty_options: LaTexTableConfig = {}
        self.__options: LaTexTableConfig = options if options is not None else default_empty_options

        if experiment_name not in self.__shared_stores:
            self.__shared_stores[experiment_name] = {}

    @property
    def name(self) -> str:
        """
        Return the logger name.

        Returns:
            name: Logger name.
        """
        return "LaTexTableLogger"

    @property
    def version(self) -> str | int:
        """
        Return the logger version.

        Returns:
            version: Model name used as the logger version.
        """
        return self.__model_name

    @property
    def store(self) -> dict[str, dict[str, Any]]:
        """Access the shared store for the current experiment."""
        return dict(self.__shared_stores.get(self.__experiment_name, {}))

    @property
    def save_dir(self) -> str | Path:
        """
        Return the logger save directory.

        Returns:
            save_dir: Base directory for comparison files.
        """
        return self.__save_dir

    @property
    def experiment_name(self) -> str | Path:
        """
        Return the experiment name.

        Returns:
            experiment_name: Shared experiment key.
        """
        return self.__experiment_name

    def clear(self, experiment_name: str) -> None:
        """
        Remove accumulated data for an experiment.

        Args:
            experiment_name: The experiment name whose data should be cleared.
        """
        self.__shared_stores.pop(experiment_name, None)

    def destroy(self) -> None:
        """
        Destroy the internal shared state of the logger.

        Caution: This method should be used with care, as it will clear all shared
            state across experiments in the current process. This means that any metrics
            or data logged by other experiments will be lost as well. Use this method only
            when you are certain that you want to clear all shared state, and not just the
            state for a specific experiment. In that case, use the `clear` methods instead.
        """
        self.__shared_stores.clear()

    def log_hyperparams(self, params: Any) -> None:
        """
        Accept hyperparameter logging calls from Lightning.

        Args:
            params: Hyperparameters provided by Lightning, unused.
        """
        pass

    @rank_zero_only
    def log_metrics(self, metrics: dict[str, Any], step: int | None = None) -> None:
        """Accumulate metrics for this model. Called by Lightning on every log step.

        Keeps only the latest value for each metric name. For example, if
        "val_auc" is logged at step 10 and step 20, only the step 20 value is kept.
        """
        store = self.__shared_stores[self.__experiment_name]
        if self.__model_name not in store:
            store[self.__model_name] = {}
        store[self.__model_name].update(metrics)

    @rank_zero_only
    def finalize(self, status: str) -> None:
        """Write the LaTex comparison table with all accumulated metrics so far.

        Called by Lightning after fit() and after test() for each model. Since models
        train/test sequentially, each finalize() overwrites the file with all data
        accumulated up to that point. The file grows more complete over time.
        """
        test_results, train_results, val_results = self.__split_results()
        if not test_results and not train_results and not val_results:
            return

        comparison_dir = Path(self.__save_dir) / "comparison"
        table_caption_opt = self.__options.get("table_caption")
        sort_by_opt = self.__options.get("sort_by")
        border_opt = self.__options.get("border")

        table_caption = table_caption_opt if isinstance(table_caption_opt, str) else None
        sort_by = sort_by_opt if isinstance(sort_by_opt, list) and sort_by_opt else ["asc"]
        border = border_opt if isinstance(border_opt, bool) else True

        self.__save_comparison_tables(
            test_results=test_results,
            save_dir=comparison_dir,
            train_results=train_results or None,
            val_results=val_results or None,
            precision=self.__precision,
            table_caption=table_caption,
            sort_by=sort_by,
            border=border,
        )
        # test
        self.__save_comparison_tables(
            test_results=test_results,
            save_dir=comparison_dir,
            train_results=None,
            val_results=None,
            precision=self.__precision,
            filename="test.tex",
            table_caption=table_caption,
            sort_by=sort_by,
            border=border,
        )
        # train
        self.__save_comparison_tables(
            test_results={},
            save_dir=comparison_dir,
            train_results=train_results or None,
            val_results=None,
            precision=self.__precision,
            filename="train.tex",
            table_caption=table_caption,
            sort_by=sort_by,
            border=border,
        )
        # val
        self.__save_comparison_tables(
            test_results={},
            save_dir=comparison_dir,
            train_results=None,
            val_results=val_results or None,
            precision=self.__precision,
            filename="val.tex",
            table_caption=table_caption,
            sort_by=sort_by,
            border=border,
        )

    def __build_comparison_table(
        self,
        sections_data: list[tuple[str, Mapping[str, Mapping[str, Any]]]],
        precision: int = 4,
        table_caption: str | None = None,
        sort_by: list[str] | None = None,
        border: bool = True,
    ) -> str:
        """
        Build a LaTex comparison table from grouped result sections.

        Args:
            sections_data: Section titles and per-model metric mappings.
            precision: Decimal places for metric values. Defaults to ``4``.
            table_caption: Optional table caption. Defaults to ``None``.
            sort_by: Optional per-metric sort directions. Defaults to ``None``.
            border: Whether to use bordered table rules. Defaults to ``True``.

        Returns:
            table: LaTex table content.
        """
        if not sections_data:
            return ""

        # One tabular must have fixed column count; use max needed across sections.
        max_metrics = max(len({m for mm in rs.values() for m in mm}) for _, rs in sections_data)
        total_cols = 1 + max_metrics
        if border:
            col_spec = "|".join(["l", *(["c"] * (total_cols - 1))])
        else:
            col_spec = "l" + "c" * (total_cols - 1)

        lines: list[str] = [
            rf"\begin{{tabular}}{{{col_spec}}}",
            r"\hline" if border else r"\toprule",
        ]

        for title, results in sections_data:
            lines.extend(
                self.__get_section_lines(title, results, total_cols, precision, sort_by, border)
            )

        # Replace the last section-ending rule with a final closing \hline.
        last_rule = r"\hline" if border else r"\midrule"
        final_rule = r"\hline"
        lines and lines[-1] == last_rule and lines.pop()
        (lines and lines[-1] == final_rule) or lines.append(final_rule)

        lines.append(r"\end{tabular}")
        table_lines: list[str] = [r"\begin{table}[htbp]", r"\centering"]

        if table_caption:
            escaped_caption = escape(table_caption, LATEX_CHARACTER_ESCAPE_TABLE)
            table_lines.append(rf"\caption{{{escaped_caption}}}")

        table_lines.extend(lines)
        table_lines.append(r"\end{table}")
        return "\n".join(table_lines) + "\n"

    def __get_section_lines(
        self,
        title: str,
        results: Mapping[str, Mapping[str, Any]],
        total_cols: int,
        precision: int,
        sort_by: list[str] | None,
        border: bool,
    ) -> list[str]:
        """
        Build LaTex table rows for a result section.

        Args:
            title: Section title.
            results: Mapping from model names to metric dictionaries.
            total_cols: Total number of columns in the shared table.
            precision: Decimal places for metric values.
            sort_by: Optional per-metric sort directions.
            border: Whether to use bordered table rules.

        Returns:
            lines: LaTex rows for the section.

        Raises:
            ValueError: If any sort direction is unsupported.
        """
        metrics = sorted({m for mm in results.values() for m in mm})
        sort_orders = sort_by or ["asc"]

        normalized_orders: list[str] = []
        for order in sort_orders:
            normalized = order.lower()
            if normalized not in ("asc", "des"):
                raise ValueError(f"Invalid 'sort_by' value: {order}. Use 'asc' or 'des'.")
            normalized_orders.append(normalized)

        metric_sort: dict[str, str] = {}
        for idx, metric in enumerate(metrics):
            metric_sort[metric] = (
                normalized_orders[idx] if idx < len(normalized_orders) else normalized_orders[-1]
            )

        metric_bounds = collect_metric_bounds(results, metrics)

        best_by_metric: dict[str, float] = {}

        for metric in metrics:
            vals = [
                metric_value
                for model_metrics in results.values()
                for metric_name, metric_value in model_metrics.items()
                if metric_name == metric and isinstance(metric_value, (int, float))
            ]
            if vals:
                best_by_metric[metric] = (
                    min(vals) if metric_sort.get(metric, "asc") == "asc" else max(vals)
                )

        header_cells = [
            "Model",
            *[escape(metric, LATEX_CHARACTER_ESCAPE_TABLE) for metric in metrics],
        ]
        while len(header_cells) < total_cols:
            header_cells.append("")

        escaped_title = escape(title, LATEX_CHARACTER_ESCAPE_TABLE)
        lines = [
            r"\addlinespace[3pt]",
            rf"\multicolumn{{{total_cols}}}{{c}}{{\textbf{{{escaped_title}}}}} \\",
            r"\midrule",
            " & ".join(header_cells) + r" \\",
        ]

        for model_name in sorted(results):
            model_metrics = results[model_name]
            row = [escape(model_name, LATEX_CHARACTER_ESCAPE_TABLE)]

            for metric in metrics:
                value = model_metrics.get(metric)
                if isinstance(value, (int, float)):
                    formatted = f"{value:.{precision}f}"
                    best = best_by_metric.get(metric)
                    if best is not None and value == best:
                        formatted = rf"\underline{{{formatted}}}"

                    row.append(
                        colorize_metric_value(
                            metric=metric,
                            value=float(value),
                            text=formatted,
                            metric_bounds=metric_bounds,
                            sort_order=metric_sort.get(metric, "asc"),
                        )
                    )
                else:
                    row.append("-")

            while len(row) < total_cols:
                row.append("")
            lines.append(" & ".join(row) + r" \\")

        lines.append(r"\hline" if border else r"\midrule")
        return lines

    def __save_comparison_tables(
        self,
        test_results: Mapping[str, Mapping[str, Any]],
        save_dir: str | Path,
        train_results: Mapping[str, Mapping[str, Any]] | None = None,
        val_results: Mapping[str, Mapping[str, Any]] | None = None,
        filename: str = "overall.tex",
        precision: int = 4,
        table_caption: str | None = None,
        sort_by: list[str] | None = None,
        border: bool = True,
    ) -> Path:
        """
        Build and save LaTex comparison tables to a file.

        Args:
            test_results: Mapping from model names to test metrics.
            save_dir: Directory where the LaTex file will be written.
            train_results: Optional mapping from model names to train metrics.
                Defaults to ``None``.
            val_results: Optional mapping from model names to validation metrics.
                Defaults to ``None``.
            filename: Name of the output file. Defaults to ``"overall.tex"``.
            precision: Decimal places for metric values. Defaults to ``4``.
            table_caption: Optional table caption. Defaults to ``None``.
            sort_by: Optional per-metric sort directions. Defaults to ``None``.
            border: Whether to use bordered table rules. Defaults to ``True``.

        Returns:
            path: Path to the written file.
        """
        sections_data: list[tuple[str, Mapping[str, Mapping[str, Any]]]] = []
        if test_results:
            sections_data.append(("Test Results", test_results))
        if train_results:
            sections_data.append(("Train Results", train_results))
        if val_results:
            sections_data.append(("Val Results", val_results))

        content = self.__build_comparison_table(
            sections_data=sections_data,
            precision=precision,
            border=border,
            table_caption=table_caption,
            sort_by=sort_by,
        )

        save_path = Path(save_dir)
        save_path.mkdir(parents=True, exist_ok=True)

        file_path = save_path / filename
        if content != "":
            content = (
                "% Requires: \\usepackage{booktabs}\n"
                "% Requires: \\usepackage[table]{xcolor}\n" + content
            )
        file_path.write_text(content)
        return file_path

    def __split_results(
        self,
    ) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]], dict[str, dict[str, Any]]]:
        """
        Split all accumulated metrics into test vs train/val groups.

        Metrics are classified by their name prefix:
        - "test/*"  -> test_results
        - "train/*" -> train_results
        - "val/*"   -> val_results
        - anything else (e.g., "epoch") -> ignored
        """
        store = self.__shared_stores.get(self.__experiment_name, {})
        test_results: dict[str, dict[str, Any]] = {}
        train_results: dict[str, dict[str, Any]] = {}
        val_results: dict[str, dict[str, Any]] = {}

        for model_name, metrics in store.items():
            test_metrics: dict[str, Any] = {}
            train_metrics: dict[str, Any] = {}
            val_metrics: dict[str, Any] = {}

            for metric_name, value in metrics.items():
                if metric_name.startswith("test/"):
                    test_metrics[metric_name] = value
                elif metric_name.startswith("train/"):
                    train_metrics[metric_name] = value
                elif metric_name.startswith("val/"):
                    val_metrics[metric_name] = value

            if test_metrics:
                test_results[model_name] = test_metrics
            if train_metrics:
                train_results[model_name] = train_metrics
            if val_metrics:
                val_results[model_name] = val_metrics

        return test_results, train_results, val_results

name property

Return the logger name.

Returns:

Name Type Description
name str

Logger name.

version property

Return the logger version.

Returns:

Name Type Description
version str | int

Model name used as the logger version.

store property

Access the shared store for the current experiment.

save_dir property

Return the logger save directory.

Returns:

Name Type Description
save_dir str | Path

Base directory for comparison files.

experiment_name property

Return the experiment name.

Returns:

Name Type Description
experiment_name str | Path

Shared experiment key.

__init__(save_dir, model_name, experiment_name, precision=4, options=None)

Initialize the LaTex table logger.

Parameters:

Name Type Description Default
save_dir str | Path

Base directory where comparison files are written.

required
model_name str

Full model name to use as the table row label.

required
experiment_name str

Shared key grouping models in the same experiment.

required
precision int

Decimal places for metric values.

4
options LaTexTableConfig | None

Optional table rendering configuration. Defaults to an empty dict configuration, if not provided.

None
Source code in hypertorch/train/latex_logger.py
def __init__(
    self,
    save_dir: str | Path,
    model_name: str,
    experiment_name: str,
    precision: int = 4,
    options: LaTexTableConfig | None = None,
) -> None:
    """
    Initialize the LaTex table logger.

    Args:
        save_dir: Base directory where comparison files are written.
        model_name: Full model name to use as the table row label.
        experiment_name: Shared key grouping models in the same experiment.
        precision: Decimal places for metric values.
        options: Optional table rendering configuration.
            Defaults to an empty dict configuration, if not provided.
    """
    super().__init__()
    validate_is_non_negative("precision", precision)

    self.__save_dir: str | Path = save_dir
    self.__model_name: str = model_name
    self.__experiment_name: str = experiment_name
    self.__precision: int = precision

    default_empty_options: LaTexTableConfig = {}
    self.__options: LaTexTableConfig = options if options is not None else default_empty_options

    if experiment_name not in self.__shared_stores:
        self.__shared_stores[experiment_name] = {}

clear(experiment_name)

Remove accumulated data for an experiment.

Parameters:

Name Type Description Default
experiment_name str

The experiment name whose data should be cleared.

required
Source code in hypertorch/train/latex_logger.py
def clear(self, experiment_name: str) -> None:
    """
    Remove accumulated data for an experiment.

    Args:
        experiment_name: The experiment name whose data should be cleared.
    """
    self.__shared_stores.pop(experiment_name, None)

destroy()

Destroy the internal shared state of the logger.

This method should be used with care, as it will clear all shared

state across experiments in the current process. This means that any metrics or data logged by other experiments will be lost as well. Use this method only when you are certain that you want to clear all shared state, and not just the state for a specific experiment. In that case, use the clear methods instead.

Source code in hypertorch/train/latex_logger.py
def destroy(self) -> None:
    """
    Destroy the internal shared state of the logger.

    Caution: This method should be used with care, as it will clear all shared
        state across experiments in the current process. This means that any metrics
        or data logged by other experiments will be lost as well. Use this method only
        when you are certain that you want to clear all shared state, and not just the
        state for a specific experiment. In that case, use the `clear` methods instead.
    """
    self.__shared_stores.clear()

log_hyperparams(params)

Accept hyperparameter logging calls from Lightning.

Parameters:

Name Type Description Default
params Any

Hyperparameters provided by Lightning, unused.

required
Source code in hypertorch/train/latex_logger.py
def log_hyperparams(self, params: Any) -> None:
    """
    Accept hyperparameter logging calls from Lightning.

    Args:
        params: Hyperparameters provided by Lightning, unused.
    """
    pass

log_metrics(metrics, step=None)

Accumulate metrics for this model. Called by Lightning on every log step.

Keeps only the latest value for each metric name. For example, if "val_auc" is logged at step 10 and step 20, only the step 20 value is kept.

Source code in hypertorch/train/latex_logger.py
@rank_zero_only
def log_metrics(self, metrics: dict[str, Any], step: int | None = None) -> None:
    """Accumulate metrics for this model. Called by Lightning on every log step.

    Keeps only the latest value for each metric name. For example, if
    "val_auc" is logged at step 10 and step 20, only the step 20 value is kept.
    """
    store = self.__shared_stores[self.__experiment_name]
    if self.__model_name not in store:
        store[self.__model_name] = {}
    store[self.__model_name].update(metrics)

finalize(status)

Write the LaTex comparison table with all accumulated metrics so far.

Called by Lightning after fit() and after test() for each model. Since models train/test sequentially, each finalize() overwrites the file with all data accumulated up to that point. The file grows more complete over time.

Source code in hypertorch/train/latex_logger.py
@rank_zero_only
def finalize(self, status: str) -> None:
    """Write the LaTex comparison table with all accumulated metrics so far.

    Called by Lightning after fit() and after test() for each model. Since models
    train/test sequentially, each finalize() overwrites the file with all data
    accumulated up to that point. The file grows more complete over time.
    """
    test_results, train_results, val_results = self.__split_results()
    if not test_results and not train_results and not val_results:
        return

    comparison_dir = Path(self.__save_dir) / "comparison"
    table_caption_opt = self.__options.get("table_caption")
    sort_by_opt = self.__options.get("sort_by")
    border_opt = self.__options.get("border")

    table_caption = table_caption_opt if isinstance(table_caption_opt, str) else None
    sort_by = sort_by_opt if isinstance(sort_by_opt, list) and sort_by_opt else ["asc"]
    border = border_opt if isinstance(border_opt, bool) else True

    self.__save_comparison_tables(
        test_results=test_results,
        save_dir=comparison_dir,
        train_results=train_results or None,
        val_results=val_results or None,
        precision=self.__precision,
        table_caption=table_caption,
        sort_by=sort_by,
        border=border,
    )
    # test
    self.__save_comparison_tables(
        test_results=test_results,
        save_dir=comparison_dir,
        train_results=None,
        val_results=None,
        precision=self.__precision,
        filename="test.tex",
        table_caption=table_caption,
        sort_by=sort_by,
        border=border,
    )
    # train
    self.__save_comparison_tables(
        test_results={},
        save_dir=comparison_dir,
        train_results=train_results or None,
        val_results=None,
        precision=self.__precision,
        filename="train.tex",
        table_caption=table_caption,
        sort_by=sort_by,
        border=border,
    )
    # val
    self.__save_comparison_tables(
        test_results={},
        save_dir=comparison_dir,
        train_results=None,
        val_results=val_results or None,
        precision=self.__precision,
        filename="val.tex",
        table_caption=table_caption,
        sort_by=sort_by,
        border=border,
    )

__build_comparison_table(sections_data, precision=4, table_caption=None, sort_by=None, border=True)

Build a LaTex comparison table from grouped result sections.

Parameters:

Name Type Description Default
sections_data list[tuple[str, Mapping[str, Mapping[str, Any]]]]

Section titles and per-model metric mappings.

required
precision int

Decimal places for metric values. Defaults to 4.

4
table_caption str | None

Optional table caption. Defaults to None.

None
sort_by list[str] | None

Optional per-metric sort directions. Defaults to None.

None
border bool

Whether to use bordered table rules. Defaults to True.

True

Returns:

Name Type Description
table str

LaTex table content.

Source code in hypertorch/train/latex_logger.py
def __build_comparison_table(
    self,
    sections_data: list[tuple[str, Mapping[str, Mapping[str, Any]]]],
    precision: int = 4,
    table_caption: str | None = None,
    sort_by: list[str] | None = None,
    border: bool = True,
) -> str:
    """
    Build a LaTex comparison table from grouped result sections.

    Args:
        sections_data: Section titles and per-model metric mappings.
        precision: Decimal places for metric values. Defaults to ``4``.
        table_caption: Optional table caption. Defaults to ``None``.
        sort_by: Optional per-metric sort directions. Defaults to ``None``.
        border: Whether to use bordered table rules. Defaults to ``True``.

    Returns:
        table: LaTex table content.
    """
    if not sections_data:
        return ""

    # One tabular must have fixed column count; use max needed across sections.
    max_metrics = max(len({m for mm in rs.values() for m in mm}) for _, rs in sections_data)
    total_cols = 1 + max_metrics
    if border:
        col_spec = "|".join(["l", *(["c"] * (total_cols - 1))])
    else:
        col_spec = "l" + "c" * (total_cols - 1)

    lines: list[str] = [
        rf"\begin{{tabular}}{{{col_spec}}}",
        r"\hline" if border else r"\toprule",
    ]

    for title, results in sections_data:
        lines.extend(
            self.__get_section_lines(title, results, total_cols, precision, sort_by, border)
        )

    # Replace the last section-ending rule with a final closing \hline.
    last_rule = r"\hline" if border else r"\midrule"
    final_rule = r"\hline"
    lines and lines[-1] == last_rule and lines.pop()
    (lines and lines[-1] == final_rule) or lines.append(final_rule)

    lines.append(r"\end{tabular}")
    table_lines: list[str] = [r"\begin{table}[htbp]", r"\centering"]

    if table_caption:
        escaped_caption = escape(table_caption, LATEX_CHARACTER_ESCAPE_TABLE)
        table_lines.append(rf"\caption{{{escaped_caption}}}")

    table_lines.extend(lines)
    table_lines.append(r"\end{table}")
    return "\n".join(table_lines) + "\n"

__get_section_lines(title, results, total_cols, precision, sort_by, border)

Build LaTex table rows for a result section.

Parameters:

Name Type Description Default
title str

Section title.

required
results Mapping[str, Mapping[str, Any]]

Mapping from model names to metric dictionaries.

required
total_cols int

Total number of columns in the shared table.

required
precision int

Decimal places for metric values.

required
sort_by list[str] | None

Optional per-metric sort directions.

required
border bool

Whether to use bordered table rules.

required

Returns:

Name Type Description
lines list[str]

LaTex rows for the section.

Raises:

Type Description
ValueError

If any sort direction is unsupported.

Source code in hypertorch/train/latex_logger.py
def __get_section_lines(
    self,
    title: str,
    results: Mapping[str, Mapping[str, Any]],
    total_cols: int,
    precision: int,
    sort_by: list[str] | None,
    border: bool,
) -> list[str]:
    """
    Build LaTex table rows for a result section.

    Args:
        title: Section title.
        results: Mapping from model names to metric dictionaries.
        total_cols: Total number of columns in the shared table.
        precision: Decimal places for metric values.
        sort_by: Optional per-metric sort directions.
        border: Whether to use bordered table rules.

    Returns:
        lines: LaTex rows for the section.

    Raises:
        ValueError: If any sort direction is unsupported.
    """
    metrics = sorted({m for mm in results.values() for m in mm})
    sort_orders = sort_by or ["asc"]

    normalized_orders: list[str] = []
    for order in sort_orders:
        normalized = order.lower()
        if normalized not in ("asc", "des"):
            raise ValueError(f"Invalid 'sort_by' value: {order}. Use 'asc' or 'des'.")
        normalized_orders.append(normalized)

    metric_sort: dict[str, str] = {}
    for idx, metric in enumerate(metrics):
        metric_sort[metric] = (
            normalized_orders[idx] if idx < len(normalized_orders) else normalized_orders[-1]
        )

    metric_bounds = collect_metric_bounds(results, metrics)

    best_by_metric: dict[str, float] = {}

    for metric in metrics:
        vals = [
            metric_value
            for model_metrics in results.values()
            for metric_name, metric_value in model_metrics.items()
            if metric_name == metric and isinstance(metric_value, (int, float))
        ]
        if vals:
            best_by_metric[metric] = (
                min(vals) if metric_sort.get(metric, "asc") == "asc" else max(vals)
            )

    header_cells = [
        "Model",
        *[escape(metric, LATEX_CHARACTER_ESCAPE_TABLE) for metric in metrics],
    ]
    while len(header_cells) < total_cols:
        header_cells.append("")

    escaped_title = escape(title, LATEX_CHARACTER_ESCAPE_TABLE)
    lines = [
        r"\addlinespace[3pt]",
        rf"\multicolumn{{{total_cols}}}{{c}}{{\textbf{{{escaped_title}}}}} \\",
        r"\midrule",
        " & ".join(header_cells) + r" \\",
    ]

    for model_name in sorted(results):
        model_metrics = results[model_name]
        row = [escape(model_name, LATEX_CHARACTER_ESCAPE_TABLE)]

        for metric in metrics:
            value = model_metrics.get(metric)
            if isinstance(value, (int, float)):
                formatted = f"{value:.{precision}f}"
                best = best_by_metric.get(metric)
                if best is not None and value == best:
                    formatted = rf"\underline{{{formatted}}}"

                row.append(
                    colorize_metric_value(
                        metric=metric,
                        value=float(value),
                        text=formatted,
                        metric_bounds=metric_bounds,
                        sort_order=metric_sort.get(metric, "asc"),
                    )
                )
            else:
                row.append("-")

        while len(row) < total_cols:
            row.append("")
        lines.append(" & ".join(row) + r" \\")

    lines.append(r"\hline" if border else r"\midrule")
    return lines

__save_comparison_tables(test_results, save_dir, train_results=None, val_results=None, filename='overall.tex', precision=4, table_caption=None, sort_by=None, border=True)

Build and save LaTex comparison tables to a file.

Parameters:

Name Type Description Default
test_results Mapping[str, Mapping[str, Any]]

Mapping from model names to test metrics.

required
save_dir str | Path

Directory where the LaTex file will be written.

required
train_results Mapping[str, Mapping[str, Any]] | None

Optional mapping from model names to train metrics. Defaults to None.

None
val_results Mapping[str, Mapping[str, Any]] | None

Optional mapping from model names to validation metrics. Defaults to None.

None
filename str

Name of the output file. Defaults to "overall.tex".

'overall.tex'
precision int

Decimal places for metric values. Defaults to 4.

4
table_caption str | None

Optional table caption. Defaults to None.

None
sort_by list[str] | None

Optional per-metric sort directions. Defaults to None.

None
border bool

Whether to use bordered table rules. Defaults to True.

True

Returns:

Name Type Description
path Path

Path to the written file.

Source code in hypertorch/train/latex_logger.py
def __save_comparison_tables(
    self,
    test_results: Mapping[str, Mapping[str, Any]],
    save_dir: str | Path,
    train_results: Mapping[str, Mapping[str, Any]] | None = None,
    val_results: Mapping[str, Mapping[str, Any]] | None = None,
    filename: str = "overall.tex",
    precision: int = 4,
    table_caption: str | None = None,
    sort_by: list[str] | None = None,
    border: bool = True,
) -> Path:
    """
    Build and save LaTex comparison tables to a file.

    Args:
        test_results: Mapping from model names to test metrics.
        save_dir: Directory where the LaTex file will be written.
        train_results: Optional mapping from model names to train metrics.
            Defaults to ``None``.
        val_results: Optional mapping from model names to validation metrics.
            Defaults to ``None``.
        filename: Name of the output file. Defaults to ``"overall.tex"``.
        precision: Decimal places for metric values. Defaults to ``4``.
        table_caption: Optional table caption. Defaults to ``None``.
        sort_by: Optional per-metric sort directions. Defaults to ``None``.
        border: Whether to use bordered table rules. Defaults to ``True``.

    Returns:
        path: Path to the written file.
    """
    sections_data: list[tuple[str, Mapping[str, Mapping[str, Any]]]] = []
    if test_results:
        sections_data.append(("Test Results", test_results))
    if train_results:
        sections_data.append(("Train Results", train_results))
    if val_results:
        sections_data.append(("Val Results", val_results))

    content = self.__build_comparison_table(
        sections_data=sections_data,
        precision=precision,
        border=border,
        table_caption=table_caption,
        sort_by=sort_by,
    )

    save_path = Path(save_dir)
    save_path.mkdir(parents=True, exist_ok=True)

    file_path = save_path / filename
    if content != "":
        content = (
            "% Requires: \\usepackage{booktabs}\n"
            "% Requires: \\usepackage[table]{xcolor}\n" + content
        )
    file_path.write_text(content)
    return file_path

__split_results()

Split all accumulated metrics into test vs train/val groups.

Metrics are classified by their name prefix: - "test/" -> test_results - "train/" -> train_results - "val/*" -> val_results - anything else (e.g., "epoch") -> ignored

Source code in hypertorch/train/latex_logger.py
def __split_results(
    self,
) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]], dict[str, dict[str, Any]]]:
    """
    Split all accumulated metrics into test vs train/val groups.

    Metrics are classified by their name prefix:
    - "test/*"  -> test_results
    - "train/*" -> train_results
    - "val/*"   -> val_results
    - anything else (e.g., "epoch") -> ignored
    """
    store = self.__shared_stores.get(self.__experiment_name, {})
    test_results: dict[str, dict[str, Any]] = {}
    train_results: dict[str, dict[str, Any]] = {}
    val_results: dict[str, dict[str, Any]] = {}

    for model_name, metrics in store.items():
        test_metrics: dict[str, Any] = {}
        train_metrics: dict[str, Any] = {}
        val_metrics: dict[str, Any] = {}

        for metric_name, value in metrics.items():
            if metric_name.startswith("test/"):
                test_metrics[metric_name] = value
            elif metric_name.startswith("train/"):
                train_metrics[metric_name] = value
            elif metric_name.startswith("val/"):
                val_metrics[metric_name] = value

        if test_metrics:
            test_results[model_name] = test_metrics
        if train_metrics:
            train_results[model_name] = train_metrics
        if val_metrics:
            val_results[model_name] = val_metrics

    return test_results, train_results, val_results

MarkdownTableLogger

Bases: ExperimentSharedLogger

A Lightning Logger that accumulates metrics and writes a markdown comparison table.

Multiple instances (one per model) share a class-level store keyed by experiment_name. Every time finalize() is called (after fit() or test() for each model), the current state of all accumulated metrics is written to a markdown file. The last model to finalize produces the most complete table.

In distributed runs, only global rank zero accumulates metrics and writes tables. Metrics must therefore be synchronized before they reach the logger, for example by passing sync_dist=True to Lightning's metric logging methods.

This means the file is progressively updated as models finish training/testing, so partial results are available while running.

Source code in hypertorch/train/markdown_logger.py
class MarkdownTableLogger(ExperimentSharedLogger):
    """
    A Lightning Logger that accumulates metrics and writes a markdown comparison table.

    Multiple instances (one per model) share a class-level store keyed by experiment_name.
    Every time ``finalize()`` is called (after ``fit()`` or ``test()`` for each model), the current
    state of all accumulated metrics is written to a markdown file. The last model to
    finalize produces the most complete table.

    In distributed runs, only global rank zero accumulates metrics and writes tables.
    Metrics must therefore be synchronized before they reach the logger, for example by
    passing ``sync_dist=True`` to Lightning's metric logging methods.

    This means the file is progressively updated as models finish training/testing,
    so partial results are available while running.
    """

    # Class-level shared store: {experiment_name: {model_name: {metric_name: value}}}
    __shared_stores: ClassVar[dict[str, dict[str, dict[str, float]]]] = {}

    def __init__(
        self,
        save_dir: str | Path,
        model_name: str,
        experiment_name: str,
        precision: int = 4,
    ) -> None:
        """
        Initialize the markdown table logger.

        Args:
            save_dir: Base directory where comparison files are written.
            model_name: Full model name to use as the table row label.
            experiment_name: Shared key grouping models in the same experiment.
            precision: Decimal places for metric values. Default is ``4``.

        Raises:
            ValueError: If ``precision`` is negative.
        """
        super().__init__()
        validate_is_non_negative("precision", precision)

        self.__save_dir: str | Path = save_dir
        self.__model_name: str = model_name
        self.__experiment_name: str = experiment_name
        self.__precision: int = precision

        if experiment_name not in self.__shared_stores:
            self.__shared_stores[experiment_name] = {}

    @property
    def name(self) -> str:
        """
        Return the logger name.

        Returns:
            name: Logger name.
        """
        return "MarkdownTableLogger"

    @property
    def version(self) -> str | int:
        """
        Return the logger version.

        Returns:
            version: Model name used as the logger version.
        """
        return self.__model_name

    @property
    def store(self) -> dict[str, dict[str, float]]:
        """Access the shared store for the current experiment."""
        return copy.deepcopy(self.__shared_stores.get(self.__experiment_name, {}))

    @property
    def save_dir(self) -> str | Path:
        """
        Return the logger save directory.

        Returns:
            save_dir: Base directory for comparison files.
        """
        return self.__save_dir

    @property
    def experiment_name(self) -> str | Path:
        """
        Return the experiment name.

        Returns:
            experiment_name: Shared experiment key.
        """
        return self.__experiment_name

    def clear(self, experiment_name: str) -> None:
        """
        Remove accumulated data for an experiment.

        Args:
            experiment_name: The experiment name whose data should be cleared.
        """
        self.__shared_stores.pop(experiment_name, None)

    def destroy(self) -> None:
        """
        Destroy the internal shared state of the logger.

        Caution: This method should be used with care, as it will clear all shared
            state across experiments in the current process. This means that any metrics
            or data logged by other experiments will be lost as well. Use this method only
            when you are certain that you want to clear all shared state, and not just the
            state for a specific experiment. In that case, use the `clear` methods instead.
        """
        self.__shared_stores.clear()

    def log_hyperparams(self, params: Any) -> None:
        """
        Accept hyperparameter logging calls from Lightning.

        Args:
            params: Hyperparameters provided by Lightning, unused.
        """
        pass

    @rank_zero_only
    def log_metrics(self, metrics: dict[str, float], step: int | None = None) -> None:
        """
        Accumulate metrics for this model. Called by Lightning on every log step.

        Keeps only the latest value for each metric name. For example, if
        "val_auc" is logged at step 10 and step 20, only the step 20 value is kept.

        Args:
            metrics: Dictionary of metric names to values.
            step: Optional step number, unused. Defaults to ``None``.
        """
        store = self.__shared_stores[self.__experiment_name]
        if self.__model_name not in store:
            store[self.__model_name] = {}
        store[self.__model_name].update(metrics)

    @rank_zero_only
    def finalize(self, status: str) -> None:
        """
        Write the markdown comparison table with all accumulated metrics so far.

        Called by Lightning after fit() and after test() for each model. Since models
        train/test sequentially, each finalize() overwrites the file with all data
        accumulated up to that point. The file grows more complete over time.

        Args:
            status: The stage that just completed, unused. For example, "fit" or "test".
        """
        test_results, train_results, val_results = self.__split_results()

        if not test_results and not train_results and not val_results:
            return

        comparison_dir = Path(self.__save_dir) / "comparison"
        self.__save_comparison_tables(
            test_results=test_results,
            save_dir=comparison_dir,
            train_results=train_results or None,
            val_results=val_results or None,
            precision=self.__precision,
            filename="overall.md",
        )
        # test
        self.__save_comparison_tables(
            test_results=test_results,
            save_dir=comparison_dir,
            train_results=None,
            val_results=None,
            precision=self.__precision,
            filename="test.md",
        )
        # train
        self.__save_comparison_tables(
            test_results={},
            save_dir=comparison_dir,
            train_results=train_results or None,
            val_results=None,
            precision=self.__precision,
            filename="train.md",
        )
        # val
        self.__save_comparison_tables(
            test_results={},
            save_dir=comparison_dir,
            train_results=None,
            val_results=val_results or None,
            precision=self.__precision,
            filename="val.md",
        )

    def __build_comparison_table(
        self,
        results: Mapping[str, Mapping[str, float]],
        precision: int = 4,
    ) -> str:
        """
        Build a markdown comparison table from model results.

        Examples:
            Input:

            ```python
            {
            "mlp:mean": {"test_auc": 0.85, "test_loss": 0.32},
            "gat:default": {"test_auc": 0.82},
            }
            ```

            Output:

            ```md
            | Model | test_auc | test_loss |
            | --- | --- | --- |
            | gat:default | 0.8200 | - |
            | mlp:mean | 0.8500 | 0.3200 |
            ```

        Args:
            results: Mapping of model names to metric dictionaries.
            precision: Number of decimal places for numeric metric values.
                Defaults to ``4``.

        Returns:
            table: Markdown table string. Returns an empty string if ``results`` is empty.
        """
        if not results:
            return ""

        # Collect all unique metric names across all models, sorted for determinism
        all_metrics = sorted(
            {metric for model_metrics in results.values() for metric in model_metrics}
        )

        # Build header row
        escaped_metrics = [
            escape(metric, MARKDOWN_CHARACTER_ESCAPE_TABLE) for metric in all_metrics
        ]
        header = "| Model | " + " | ".join(escaped_metrics) + " |"
        separator = "| --- | " + " | ".join("---" for _ in all_metrics) + " |"

        # Build one row per model, sorted by model name for determinism
        rows = []
        for model_name in sorted(results):
            model_metrics = results[model_name]
            cells = []
            for metric in all_metrics:
                value = model_metrics.get(metric)
                if isinstance(value, (int, float)):
                    cells.append(f"{value:.{precision}f}")
                else:
                    cells.append("-")
            rows.append(
                f"| {escape(model_name, MARKDOWN_CHARACTER_ESCAPE_TABLE)} | "
                + " | ".join(cells)
                + " |"
            )

        return "\n".join([header, separator, *rows])

    def __save_comparison_tables(
        self,
        test_results: Mapping[str, Mapping[str, float]],
        save_dir: str | Path,
        train_results: Mapping[str, Mapping[str, float]] | None = None,
        val_results: Mapping[str, Mapping[str, float]] | None = None,
        filename: str = "results.md",
        precision: int = 4,
    ) -> Path:
        """
        Build and save markdown comparison tables to a file.

        Writes two sections:
        - "## Test Results" with the test metrics table
        - "## Train/Val Results" with the train/val metrics table (if provided)

        Args:
            test_results: Dict from test_all(), mapping model names to test metric dicts.
            save_dir: Directory where the markdown file will be written.
            train_results: Optional dict mapping model names to train metric dicts.
                Defaults to ``None``.
            val_results: Optional dict mapping model names to val metric dicts.
                Defaults to ``None``.
            filename: Name of the output file. Defaults to ``"results.md"``.
            precision: Decimal places for metric values. Defaults to ``4``.

        Returns:
            path: Path to the written file.
        """
        sections = []

        # Test results table
        test_table = self.__build_comparison_table(test_results, precision)
        if test_table:
            sections.append(f"## Test Results\n\n{test_table}")

        # Train/val results table
        if train_results or val_results:
            if train_results:
                train_table = self.__build_comparison_table(train_results, precision)
                sections.append(f"## Train Results\n\n{train_table}")
            if val_results:
                val_table = self.__build_comparison_table(val_results, precision)
                sections.append(f"## Val Results\n\n{val_table}")

        content = "\n\n".join(sections) + "\n" if sections else ""

        save_path = Path(save_dir)
        save_path.mkdir(parents=True, exist_ok=True)

        file_path = save_path / filename
        file_path.write_text(content)

        return file_path

    def __split_results(
        self,
    ) -> tuple[
        dict[str, dict[str, float]], dict[str, dict[str, float]], dict[str, dict[str, float]]
    ]:
        """
        Split all accumulated metrics into test vs train/val groups.

        Metrics are classified by their name prefix:
        - "test/*"  -> test_results
        - "train/*" -> train_results
        - "val/*"   -> val_results
        - anything else (e.g., "epoch") -> ignored

        Returns:
            results: Tuple of (test_results, train_results, val_results), where each is a dict
            mapping model names to their respective metric dicts. Models with no metrics
            in a category are excluded from that category's dict.
        """
        store = self.__shared_stores.get(self.__experiment_name, {})
        test_results: dict[str, dict[str, float]] = {}
        train_results: dict[str, dict[str, float]] = {}
        val_results: dict[str, dict[str, float]] = {}

        for model_name, metrics in store.items():
            test_metrics: dict[str, float] = {}
            train_metrics: dict[str, float] = {}
            val_metrics: dict[str, float] = {}

            for metric_name, value in metrics.items():
                if metric_name.startswith("test/"):
                    test_metrics[metric_name] = value
                elif metric_name.startswith("train/"):
                    train_metrics[metric_name] = value
                elif metric_name.startswith("val/"):
                    val_metrics[metric_name] = value

            if test_metrics:
                test_results[model_name] = test_metrics
            if train_metrics:
                train_results[model_name] = train_metrics
            if val_metrics:
                val_results[model_name] = val_metrics

        return test_results, train_results, val_results

name property

Return the logger name.

Returns:

Name Type Description
name str

Logger name.

version property

Return the logger version.

Returns:

Name Type Description
version str | int

Model name used as the logger version.

store property

Access the shared store for the current experiment.

save_dir property

Return the logger save directory.

Returns:

Name Type Description
save_dir str | Path

Base directory for comparison files.

experiment_name property

Return the experiment name.

Returns:

Name Type Description
experiment_name str | Path

Shared experiment key.

__init__(save_dir, model_name, experiment_name, precision=4)

Initialize the markdown table logger.

Parameters:

Name Type Description Default
save_dir str | Path

Base directory where comparison files are written.

required
model_name str

Full model name to use as the table row label.

required
experiment_name str

Shared key grouping models in the same experiment.

required
precision int

Decimal places for metric values. Default is 4.

4

Raises:

Type Description
ValueError

If precision is negative.

Source code in hypertorch/train/markdown_logger.py
def __init__(
    self,
    save_dir: str | Path,
    model_name: str,
    experiment_name: str,
    precision: int = 4,
) -> None:
    """
    Initialize the markdown table logger.

    Args:
        save_dir: Base directory where comparison files are written.
        model_name: Full model name to use as the table row label.
        experiment_name: Shared key grouping models in the same experiment.
        precision: Decimal places for metric values. Default is ``4``.

    Raises:
        ValueError: If ``precision`` is negative.
    """
    super().__init__()
    validate_is_non_negative("precision", precision)

    self.__save_dir: str | Path = save_dir
    self.__model_name: str = model_name
    self.__experiment_name: str = experiment_name
    self.__precision: int = precision

    if experiment_name not in self.__shared_stores:
        self.__shared_stores[experiment_name] = {}

clear(experiment_name)

Remove accumulated data for an experiment.

Parameters:

Name Type Description Default
experiment_name str

The experiment name whose data should be cleared.

required
Source code in hypertorch/train/markdown_logger.py
def clear(self, experiment_name: str) -> None:
    """
    Remove accumulated data for an experiment.

    Args:
        experiment_name: The experiment name whose data should be cleared.
    """
    self.__shared_stores.pop(experiment_name, None)

destroy()

Destroy the internal shared state of the logger.

This method should be used with care, as it will clear all shared

state across experiments in the current process. This means that any metrics or data logged by other experiments will be lost as well. Use this method only when you are certain that you want to clear all shared state, and not just the state for a specific experiment. In that case, use the clear methods instead.

Source code in hypertorch/train/markdown_logger.py
def destroy(self) -> None:
    """
    Destroy the internal shared state of the logger.

    Caution: This method should be used with care, as it will clear all shared
        state across experiments in the current process. This means that any metrics
        or data logged by other experiments will be lost as well. Use this method only
        when you are certain that you want to clear all shared state, and not just the
        state for a specific experiment. In that case, use the `clear` methods instead.
    """
    self.__shared_stores.clear()

log_hyperparams(params)

Accept hyperparameter logging calls from Lightning.

Parameters:

Name Type Description Default
params Any

Hyperparameters provided by Lightning, unused.

required
Source code in hypertorch/train/markdown_logger.py
def log_hyperparams(self, params: Any) -> None:
    """
    Accept hyperparameter logging calls from Lightning.

    Args:
        params: Hyperparameters provided by Lightning, unused.
    """
    pass

log_metrics(metrics, step=None)

Accumulate metrics for this model. Called by Lightning on every log step.

Keeps only the latest value for each metric name. For example, if "val_auc" is logged at step 10 and step 20, only the step 20 value is kept.

Parameters:

Name Type Description Default
metrics dict[str, float]

Dictionary of metric names to values.

required
step int | None

Optional step number, unused. Defaults to None.

None
Source code in hypertorch/train/markdown_logger.py
@rank_zero_only
def log_metrics(self, metrics: dict[str, float], step: int | None = None) -> None:
    """
    Accumulate metrics for this model. Called by Lightning on every log step.

    Keeps only the latest value for each metric name. For example, if
    "val_auc" is logged at step 10 and step 20, only the step 20 value is kept.

    Args:
        metrics: Dictionary of metric names to values.
        step: Optional step number, unused. Defaults to ``None``.
    """
    store = self.__shared_stores[self.__experiment_name]
    if self.__model_name not in store:
        store[self.__model_name] = {}
    store[self.__model_name].update(metrics)

finalize(status)

Write the markdown comparison table with all accumulated metrics so far.

Called by Lightning after fit() and after test() for each model. Since models train/test sequentially, each finalize() overwrites the file with all data accumulated up to that point. The file grows more complete over time.

Parameters:

Name Type Description Default
status str

The stage that just completed, unused. For example, "fit" or "test".

required
Source code in hypertorch/train/markdown_logger.py
@rank_zero_only
def finalize(self, status: str) -> None:
    """
    Write the markdown comparison table with all accumulated metrics so far.

    Called by Lightning after fit() and after test() for each model. Since models
    train/test sequentially, each finalize() overwrites the file with all data
    accumulated up to that point. The file grows more complete over time.

    Args:
        status: The stage that just completed, unused. For example, "fit" or "test".
    """
    test_results, train_results, val_results = self.__split_results()

    if not test_results and not train_results and not val_results:
        return

    comparison_dir = Path(self.__save_dir) / "comparison"
    self.__save_comparison_tables(
        test_results=test_results,
        save_dir=comparison_dir,
        train_results=train_results or None,
        val_results=val_results or None,
        precision=self.__precision,
        filename="overall.md",
    )
    # test
    self.__save_comparison_tables(
        test_results=test_results,
        save_dir=comparison_dir,
        train_results=None,
        val_results=None,
        precision=self.__precision,
        filename="test.md",
    )
    # train
    self.__save_comparison_tables(
        test_results={},
        save_dir=comparison_dir,
        train_results=train_results or None,
        val_results=None,
        precision=self.__precision,
        filename="train.md",
    )
    # val
    self.__save_comparison_tables(
        test_results={},
        save_dir=comparison_dir,
        train_results=None,
        val_results=val_results or None,
        precision=self.__precision,
        filename="val.md",
    )

__build_comparison_table(results, precision=4)

Build a markdown comparison table from model results.

Examples:

Input:

{
"mlp:mean": {"test_auc": 0.85, "test_loss": 0.32},
"gat:default": {"test_auc": 0.82},
}

Output:

| Model | test_auc | test_loss |
| --- | --- | --- |
| gat:default | 0.8200 | - |
| mlp:mean | 0.8500 | 0.3200 |

Parameters:

Name Type Description Default
results Mapping[str, Mapping[str, float]]

Mapping of model names to metric dictionaries.

required
precision int

Number of decimal places for numeric metric values. Defaults to 4.

4

Returns:

Name Type Description
table str

Markdown table string. Returns an empty string if results is empty.

Source code in hypertorch/train/markdown_logger.py
def __build_comparison_table(
    self,
    results: Mapping[str, Mapping[str, float]],
    precision: int = 4,
) -> str:
    """
    Build a markdown comparison table from model results.

    Examples:
        Input:

        ```python
        {
        "mlp:mean": {"test_auc": 0.85, "test_loss": 0.32},
        "gat:default": {"test_auc": 0.82},
        }
        ```

        Output:

        ```md
        | Model | test_auc | test_loss |
        | --- | --- | --- |
        | gat:default | 0.8200 | - |
        | mlp:mean | 0.8500 | 0.3200 |
        ```

    Args:
        results: Mapping of model names to metric dictionaries.
        precision: Number of decimal places for numeric metric values.
            Defaults to ``4``.

    Returns:
        table: Markdown table string. Returns an empty string if ``results`` is empty.
    """
    if not results:
        return ""

    # Collect all unique metric names across all models, sorted for determinism
    all_metrics = sorted(
        {metric for model_metrics in results.values() for metric in model_metrics}
    )

    # Build header row
    escaped_metrics = [
        escape(metric, MARKDOWN_CHARACTER_ESCAPE_TABLE) for metric in all_metrics
    ]
    header = "| Model | " + " | ".join(escaped_metrics) + " |"
    separator = "| --- | " + " | ".join("---" for _ in all_metrics) + " |"

    # Build one row per model, sorted by model name for determinism
    rows = []
    for model_name in sorted(results):
        model_metrics = results[model_name]
        cells = []
        for metric in all_metrics:
            value = model_metrics.get(metric)
            if isinstance(value, (int, float)):
                cells.append(f"{value:.{precision}f}")
            else:
                cells.append("-")
        rows.append(
            f"| {escape(model_name, MARKDOWN_CHARACTER_ESCAPE_TABLE)} | "
            + " | ".join(cells)
            + " |"
        )

    return "\n".join([header, separator, *rows])

__save_comparison_tables(test_results, save_dir, train_results=None, val_results=None, filename='results.md', precision=4)

Build and save markdown comparison tables to a file.

Writes two sections: - "## Test Results" with the test metrics table - "## Train/Val Results" with the train/val metrics table (if provided)

Parameters:

Name Type Description Default
test_results Mapping[str, Mapping[str, float]]

Dict from test_all(), mapping model names to test metric dicts.

required
save_dir str | Path

Directory where the markdown file will be written.

required
train_results Mapping[str, Mapping[str, float]] | None

Optional dict mapping model names to train metric dicts. Defaults to None.

None
val_results Mapping[str, Mapping[str, float]] | None

Optional dict mapping model names to val metric dicts. Defaults to None.

None
filename str

Name of the output file. Defaults to "results.md".

'results.md'
precision int

Decimal places for metric values. Defaults to 4.

4

Returns:

Name Type Description
path Path

Path to the written file.

Source code in hypertorch/train/markdown_logger.py
def __save_comparison_tables(
    self,
    test_results: Mapping[str, Mapping[str, float]],
    save_dir: str | Path,
    train_results: Mapping[str, Mapping[str, float]] | None = None,
    val_results: Mapping[str, Mapping[str, float]] | None = None,
    filename: str = "results.md",
    precision: int = 4,
) -> Path:
    """
    Build and save markdown comparison tables to a file.

    Writes two sections:
    - "## Test Results" with the test metrics table
    - "## Train/Val Results" with the train/val metrics table (if provided)

    Args:
        test_results: Dict from test_all(), mapping model names to test metric dicts.
        save_dir: Directory where the markdown file will be written.
        train_results: Optional dict mapping model names to train metric dicts.
            Defaults to ``None``.
        val_results: Optional dict mapping model names to val metric dicts.
            Defaults to ``None``.
        filename: Name of the output file. Defaults to ``"results.md"``.
        precision: Decimal places for metric values. Defaults to ``4``.

    Returns:
        path: Path to the written file.
    """
    sections = []

    # Test results table
    test_table = self.__build_comparison_table(test_results, precision)
    if test_table:
        sections.append(f"## Test Results\n\n{test_table}")

    # Train/val results table
    if train_results or val_results:
        if train_results:
            train_table = self.__build_comparison_table(train_results, precision)
            sections.append(f"## Train Results\n\n{train_table}")
        if val_results:
            val_table = self.__build_comparison_table(val_results, precision)
            sections.append(f"## Val Results\n\n{val_table}")

    content = "\n\n".join(sections) + "\n" if sections else ""

    save_path = Path(save_dir)
    save_path.mkdir(parents=True, exist_ok=True)

    file_path = save_path / filename
    file_path.write_text(content)

    return file_path

__split_results()

Split all accumulated metrics into test vs train/val groups.

Metrics are classified by their name prefix: - "test/" -> test_results - "train/" -> train_results - "val/*" -> val_results - anything else (e.g., "epoch") -> ignored

Returns:

Name Type Description
results dict[str, dict[str, float]]

Tuple of (test_results, train_results, val_results), where each is a dict

dict[str, dict[str, float]]

mapping model names to their respective metric dicts. Models with no metrics

dict[str, dict[str, float]]

in a category are excluded from that category's dict.

Source code in hypertorch/train/markdown_logger.py
def __split_results(
    self,
) -> tuple[
    dict[str, dict[str, float]], dict[str, dict[str, float]], dict[str, dict[str, float]]
]:
    """
    Split all accumulated metrics into test vs train/val groups.

    Metrics are classified by their name prefix:
    - "test/*"  -> test_results
    - "train/*" -> train_results
    - "val/*"   -> val_results
    - anything else (e.g., "epoch") -> ignored

    Returns:
        results: Tuple of (test_results, train_results, val_results), where each is a dict
        mapping model names to their respective metric dicts. Models with no metrics
        in a category are excluded from that category's dict.
    """
    store = self.__shared_stores.get(self.__experiment_name, {})
    test_results: dict[str, dict[str, float]] = {}
    train_results: dict[str, dict[str, float]] = {}
    val_results: dict[str, dict[str, float]] = {}

    for model_name, metrics in store.items():
        test_metrics: dict[str, float] = {}
        train_metrics: dict[str, float] = {}
        val_metrics: dict[str, float] = {}

        for metric_name, value in metrics.items():
            if metric_name.startswith("test/"):
                test_metrics[metric_name] = value
            elif metric_name.startswith("train/"):
                train_metrics[metric_name] = value
            elif metric_name.startswith("val/"):
                val_metrics[metric_name] = value

        if test_metrics:
            test_results[model_name] = test_metrics
        if train_metrics:
            train_results[model_name] = train_metrics
        if val_metrics:
            val_results[model_name] = val_metrics

    return test_results, train_results, val_results

MultiModelTrainer

A trainer class to handle training multiple models with individual trainers.

When experiment_name is None, an auto-selected directory is exported through HYPERTORCH_AUTO_EXPERIMENT_DIR_<index>. This is for distributed settings, as Lightning child processes launched after this trainer is constructed inherit that value and reuse the parent's directory. The zero-based index identifies the construction order of auto-named MultiModelTrainer instances, so relaunched processes must construct them in the same order.

External launchers such as torchrun start sibling processes before this code runs, so environment changes made by one rank are not propagated to the others. With such launchers, pass the same explicit experiment_name to every rank or preconfigure the indexed environment variables before launch. For example, HYPERTORCH_AUTO_EXPERIMENT_DIR_0="..." HYPERTORCH_AUTO_EXPERIMENT_DIR_1="..." ... The resolved directory must be accessible from every rank; multi-machine jobs therefore require shared storage.

Attributes:

Name Type Description
model_configs list[ModelConfig]

Model configurations managed by the trainer.

log_dir Path

Directory used for logs and default checkpoints.

auto_start_tensorboard bool

Whether TensorBoard should be started automatically.

tensorboard_port int

Port used by the auto-started TensorBoard server.

auto_wait bool

Whether finalize should wait before stopping TensorBoard.

Source code in hypertorch/train/trainer.py
  34
  35
  36
  37
  38
  39
  40
  41
  42
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
class MultiModelTrainer:
    """
    A trainer class to handle training multiple models with individual trainers.

    When ``experiment_name`` is ``None``, an auto-selected directory is exported through
    ``HYPERTORCH_AUTO_EXPERIMENT_DIR_<index>``. This is for distributed settings, as
    Lightning child processes launched after this trainer is constructed inherit
    that value and reuse the parent's directory.
    The zero-based index identifies the construction order of auto-named
    ``MultiModelTrainer`` instances, so relaunched processes must construct them in the
    same order.

    External launchers such as ``torchrun`` start sibling processes before this code runs,
    so environment changes made by one rank are not propagated to the others. With such
    launchers, pass the same explicit ``experiment_name`` to every rank or preconfigure the
    indexed environment variables before launch. For example,
    ``HYPERTORCH_AUTO_EXPERIMENT_DIR_0="..." HYPERTORCH_AUTO_EXPERIMENT_DIR_1="..." ...``
    The resolved directory must be accessible from every rank;
    multi-machine jobs therefore require shared storage.

    Attributes:
        model_configs: Model configurations managed by the trainer.
        log_dir: Directory used for logs and default checkpoints.
        auto_start_tensorboard: Whether TensorBoard should be started automatically.
        tensorboard_port: Port used by the auto-started TensorBoard server.
        auto_wait: Whether ``finalize`` should wait before stopping TensorBoard.
    """

    DEFAULT_BASE_LOG_DIR = "hypertorch_logs"
    EXPERIMENT_NAME_PREFIX = "experiment"
    EXPERIMENT_SEPARATOR = "_"
    FIRST_EXPERIMENT_NUMBER = 0
    VERSION_NAME_PREFIX = "version"

    DEFAULT_BASE_CHECKPOINT_DIR = "checkpoints"

    __UNKNOWN_DEVICE = "unknown"

    def __init__(
        self,
        model_configs: list[ModelConfig],
        experiment_name: str | None = None,
        # args to pass to each Trainer
        accelerator: str | Accelerator = "auto",
        devices: list[int] | str | int = "auto",
        test_devices: list[int] | str | int | None = None,
        strategy: str | Strategy = "auto",
        num_nodes: int = 1,
        precision: Any
        | None = None,  # Any as Lightning accepts multiple types (int, str, Literal, etc.)
        max_epochs: int | None = None,
        min_epochs: int | None = None,
        max_steps: int = -1,
        min_steps: int | None = None,
        check_val_every_n_epoch: int | None = 1,
        logger: Logger | Iterable[Logger] | bool | None = None,
        default_root_dir: str | Path | None = None,
        enable_autolog_hparams: bool = True,
        log_every_n_steps: int | None = None,
        profiler: Profiler | str | None = None,
        fast_dev_run: int | bool = False,
        enable_checkpointing: bool = True,
        enable_progress_bar: bool = True,
        enable_model_summary: bool | None = None,
        callbacks: list[Callback] | Callback | None = None,
        checkpoint_callback_kwargs: dict[str, Any] | None = None,
        auto_start_tensorboard: bool = False,
        tensorboard_port: int = 6006,
        auto_wait: bool = False,
        **kwargs: Any,
    ) -> None:
        """
        Initialize trainers for one or more model configurations.

        Args:
            model_configs: A list of ModelConfig objects, each containing a model and its
                associated trainer (if any).
            experiment_name: Name for this experiment run's log directory. When ``None`` (default),
                auto-increments as ``experiment_0``, ``experiment_1``, etc. under
                the log root directory. Lightning-launched child processes on the same machine
                inherit the selected directory. For external launchers such as ``torchrun``,
                provide the same explicit name on every rank or set
                ``HYPERTORCH_AUTO_EXPERIMENT_DIR_<index>`` before launch.
            accelerator: Supports passing different accelerator types
                ("cpu", "gpu", "tpu", "hpu", "mps", "auto") as well as custom accelerator instances.
            devices: The devices to use. Can be set to a positive number (int or str), a
                sequence of device indices (list or str), the value ``-1`` to indicate all available
                devices should be used, or ``"auto"`` for automatic selection based on the chosen
                accelerator. Defaults to ``"auto"``.
            test_devices: Optional device configuration for automatically-created test trainers.
                When set, ``test_all`` uses a separate Trainer with the same Trainer parameters as
                training except for ``devices``. This is useful for running distributed training
                but single-device testing, e.g. ``test_devices=1``. Defaults to ``None``,
                which makes testing use the fit trainer unless a
                ``ModelConfig.test_trainer`` is provided.
            strategy: Supports different training strategies with aliases as well custom strategies.
                Defaults to ``"auto"``.
            num_nodes: Number of GPU nodes for distributed training. Defaults to ``1``.
            precision: Double precision (64, '64' or '64-true'),
                full precision (32, '32' or '32-true'), 16bit mixed precision (16, '16', '16-mixed')
                or bfloat16 mixed precision ('bf16', 'bf16-mixed').
                Can be used on CPU, GPU, TPUs, or HPUs. Defaults to ``'32-true'``.
            max_epochs: Stop training once this number of epochs is reached.
                Disabled by default (None). If both max_epochs and max_steps are not specified,
                defaults to ``max_epochs = 1000``. To enable infinite training,
                set ``max_epochs = -1``.
            min_epochs: Force training for at least these many epochs. Disabled by default (None).
            max_steps: Stop training after this number of steps. Disabled by default (-1).
                If ``max_steps = -1`` and ``max_epochs = None``, will default to
                ``max_epochs = 1000``. To enable infinite training, set ``max_epochs`` to ``-1``.
            min_steps: Force training for at least these number of steps.
                Disabled by default (``None``).
            check_val_every_n_epoch: Perform a validation loop after every `N` training epochs.
                If ``None``, validation will be done solely based on the number of training batches,
                requiring ``val_check_interval`` to be an integer value. When used together with a
                time-based ``val_check_interval`` and ``check_val_every_n_epoch`` > 1, validation is
                aligned to epoch multiples: if the interval elapses before the next multiple-N
                epoch, validation runs at the start of that epoch (after the first batch) and the
                timer resets; if it elapses during a multiple-N epoch, validation runs after the
                current batch. For ``None`` or ``1`` cases, the time-based behavior of
                ``val_check_interval`` applies without additional alignment. Defaults to ``1``.
            logger: Logger (or iterable collection of loggers) for experiment tracking. A ``True``
                value uses the default ``TensorBoardLogger`` if it is installed,
                otherwise ``CSVLogger``. ``False`` will disable logging. If multiple loggers are
                provided, local files (checkpoints, profiler traces, etc.) are saved in the
                ``log_dir`` of the first logger. Defaults to ``True``.
            default_root_dir: Default path for logs and weights when no logger/ckpt_callback passed.
                Defaults to ``os.getcwd()``.
                Can be remote file paths such as `s3://mybucket/path` or 'hdfs://path/'
            enable_autolog_hparams: Whether to log hyperparameters at the start of a run.
                Defaults to ``True``.
            log_every_n_steps: How often to log within steps.
                Defaults to ``50``.
            profiler: To profile individual steps during training and assist in
                identifying bottlenecks. Defaults to ``None``.
            fast_dev_run: Runs n if set to ``n`` (int) else 1 if set to ``True`` batch(es)
                of train, val and test to find any bugs (ie: a sort of unit test).
                Defaults to ``False``.
            enable_checkpointing: If ``True``, enable checkpointing.
                It will configure a default ModelCheckpoint callback if there is no user-defined
                    ModelCheckpoint in :paramref:`~hypertorch.train.MultiModelTrainer.callbacks`.
                Defaults to ``True``.
            enable_progress_bar: Whether to enable the progress bar by default.
                Defaults to ``True``.
            enable_model_summary: Whether to enable model summarization by default.
                Defaults to ``True``.
            callbacks: Add a callback or list of callbacks.
                Defaults to ``None``.
            checkpoint_callback_kwargs: Keyword arguments passed to the default
                ``ModelCheckpoint`` callback when checkpointing is enabled and no
                user-defined ``ModelCheckpoint`` is provided. Pass ``dirpath`` to
                override the default checkpoint directory.
                Defaults to ``None``.
            auto_start_tensorboard: When ``True`` and tensorboard is installed, automatically
                starts a TensorBoard server pointing at the experiment log directory.
                Using this option requires that TensorBoard is installed in the environment and
                moves control of the TensorBoard server lifecycle to the trainer, which will
                automatically terminate the server when the trainer is finalized
                (e.g., at the end of a ``with`` block or when the object is garbage collected).
                Enable `auto_wait` to keep the server alive after training completes so you can
                inspect results before the trainer is finalized. Defaults to ``False``.
            tensorboard_port: Port for the auto-launched TensorBoard server.
                Defaults to ``6006``.
            auto_wait: When ``True`` and a TensorBoard server is running, automatically calls
                ``wait`` inside `finalize` before terminating the server, so the user
                can inspect results before the process is stopped.
                Defaults to ``False``.

            kwargs:
                max_time: Maximum wall-clock training time. Passed to Lightning's ``Trainer``.
                limit_train_batches: Fraction or number of training batches to use.
                limit_val_batches: Fraction or number of validation batches to use.
                limit_test_batches: Fraction or number of test batches to use.
                limit_predict_batches: Fraction or number of prediction batches to use.
                overfit_batches: Fraction or number of batches to use for overfitting checks.
                val_check_interval: How often to run validation within a training epoch.
                num_sanity_val_steps: Number of validation batches to run before training starts.
                accumulate_grad_batches: Number of batches over which to accumulate gradients.
                gradient_clip_val: Value used for gradient clipping.
                gradient_clip_algorithm: Gradient clipping algorithm to use.
                deterministic: Whether to enable deterministic operations, or ``"warn"``
                    to warn when deterministic execution is not available.
                benchmark: Whether to enable cuDNN benchmarking.
                inference_mode: Whether validation, test, and prediction loops should use
                    ``torch.inference_mode``.
                use_distributed_sampler: Whether Lightning should inject distributed samplers for
                    distributed training.
                detect_anomaly: Whether to enable PyTorch autograd anomaly detection.
                barebones: Whether to disable features that may affect raw training-loop speed.
                plugins: Lightning plugins or list of plugins to use.
                sync_batchnorm: Whether to synchronize batch normalization across devices.
                reload_dataloaders_every_n_epochs: How often to recreate dataloaders.
                model_registry: Name of the Lightning model registry to use.

        Raises:
            ValueError: If no model configurations are provided or numeric settings are invalid.
        """
        self.auto_wait: bool = auto_wait
        self.__tensorboard_process: subprocess.Popen | None = None
        validate_is_non_negative("tensorboard_port", tensorboard_port)

        self.model_configs: list[ModelConfig] = model_configs
        validate_is_non_empty("model_configs", self.model_configs)

        self.log_dir: Path = self.__logdir(default_root_dir, experiment_name)
        self.__experiment_key = str(self.log_dir)

        self.auto_start_tensorboard: bool = auto_start_tensorboard
        self.tensorboard_port: int = tensorboard_port
        self.__shared_checkpoint_callback_kwargs: dict[str, Any] = (
            checkpoint_callback_kwargs if checkpoint_callback_kwargs is not None else {}
        )

        shared_trainer_options: dict[str, Any] = {
            "accelerator": accelerator,
            "devices": devices,
            "strategy": strategy,
            "num_nodes": num_nodes,
            "precision": precision,
            "max_epochs": max_epochs,
            "min_epochs": min_epochs,
            "max_steps": max_steps,
            "min_steps": min_steps,
            "check_val_every_n_epoch": check_val_every_n_epoch,
            "logger": logger,
            "default_root_dir": default_root_dir,
            "enable_autolog_hparams": enable_autolog_hparams,
            "log_every_n_steps": log_every_n_steps,
            "profiler": profiler,
            "fast_dev_run": fast_dev_run,
            "enable_checkpointing": enable_checkpointing,
            "enable_progress_bar": enable_progress_bar,
            "enable_model_summary": enable_model_summary,
            "callbacks": callbacks,
            **kwargs,
        }

        full_model_name_counts = self.__full_model_name_counts(model_configs)
        for model_index, model_config in enumerate(model_configs):
            model_options = dict(model_config.trainer_kwargs or {})
            model_test_devices = model_options.pop("test_devices", test_devices)
            model_checkpoint_callback_kwargs = model_options.pop(
                "checkpoint_callback_kwargs",
                None,
            )

            should_create_trainer = model_config.trainer is None
            should_create_test_trainer = (
                model_config.test_trainer is None and model_test_devices is not None
            )
            should_create_at_least_one_trainer = should_create_trainer or should_create_test_trainer

            if should_create_at_least_one_trainer:
                has_duplicate_full_model_name = (
                    full_model_name_counts[model_config.full_model_name()] > 1
                )

                trainer_options = {**shared_trainer_options, **model_options}
                trainer_options["logger"] = self.__setup_logger(
                    model_config=model_config,
                    logger=cast(
                        Logger | Iterable[Logger] | bool | None,
                        trainer_options["logger"],
                    ),
                )
                trainer_options["callbacks"] = self.__setup_callbacks(
                    model_config=model_config,
                    model_index=model_index,
                    has_duplicate_full_model_name=has_duplicate_full_model_name,
                    callbacks=cast(
                        list[Callback] | Callback | None,
                        trainer_options["callbacks"],
                    ),
                    enable_checkpointing=cast(bool, trainer_options["enable_checkpointing"]),
                    checkpoint_callback_kwargs=self.__merge_checkpoint_callback_kwargs(
                        model_checkpoint_callback_kwargs
                    ),
                )

                if should_create_trainer:
                    model_config.trainer = self.__setup_trainer(trainer_options)

                if should_create_test_trainer:
                    test_trainer_options = {**trainer_options, "devices": model_test_devices}
                    model_config.test_trainer = self.__setup_trainer(test_trainer_options)

        print(f"Initialized trainer(models: {len(model_configs)}, log_dir: {self.log_dir})")
        self.__auto_start_tensorboard_if_enabled()

    def __enter__(self) -> MultiModelTrainer:
        """
        Enter the trainer context manager.

        Returns:
            trainer: This trainer instance.
        """
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc_val: BaseException | None,
        exc_tb: Any,
    ) -> None:
        """
        Exit the trainer context manager and finalize resources.

        Args:
            exc_type: Exception type from the context, if any, unused.
            exc_val: Exception value from the context, if any, unused.
            exc_tb: Exception traceback from the context, if any, unused.
        """
        self.finalize()

    def __del__(self) -> None:
        """
        Finalize external resources during object cleanup.
        """
        try:
            self.finalize()
        except Exception as e:
            warnings.warn(
                f"Exception occurred during {self.__class__.__name__} cleanup. Error: {e}",
                category=UserWarning,
                stacklevel=2,
            )

    @property
    def distributed_global_rank(self) -> int:
        """
        Return the global rank of the current process.

        Returns:
            global_rank: The global rank of the current process.
        """
        if torch.distributed.is_available() and torch.distributed.is_initialized():
            return torch.distributed.get_rank()
        return 0  # if distributed not available or not initialized, we are in single-process setup

    @property
    def is_global_zero(self) -> bool:
        """
        Return whether the current process is the global zero process.

        Check first if any of the trainers is the global zero, which covers the case where
        a custom strategy that handles distributed differently was provided.
        If no trainer is available, fall back to checking the global rank.

        Returns:
            is_global_zero: ``True`` if the current process is the global zero process,
                ``False`` otherwise.
        """
        model_config = self.model_configs[0]  # It's a process-level property, so we can check any
        trainer = (
            model_config.trainer if model_config.trainer is not None else model_config.test_trainer
        )
        if trainer is not None:
            return trainer.is_global_zero
        return self.distributed_global_rank == 0

    @property
    def models(self) -> list[L.LightningModule]:
        """
        Return all configured Lightning modules.

        Returns:
            models: List of configured models.
        """
        return [config.model for config in self.model_configs]

    def model(self, name: str, version: str = "default") -> L.LightningModule | None:
        """
        Return a configured model by name and version.

        Args:
            name: Model name.
            version: Model version. Defaults to ``"default"``.

        Returns:
            model: Matching model, or ``None`` when no model matches.
        """
        for config in self.model_configs:
            if config.name == name and config.version == version:
                return config.model
        return None

    def fit_all(
        self,
        train_dataloader: DataLoader | None = None,
        val_dataloader: DataLoader | None = None,
        datamodule: DataModule | None = None,
        ckpt_path: CkptStrategy | None = None,
        verbose: bool = True,
    ) -> None:
        """
        Fit every trainable configured model.

        Args:
            train_dataloader: Shared training dataloader used when a model has no override.
                Defaults to ``None``, which requires each model to have its own
                training dataloader provided.
            val_dataloader: Shared validation dataloader used when a model has no override.
                Defaults to ``None``, which requires each model to have its own
                validation dataloader provided.
            datamodule: Optional Lightning datamodule.
                Defaults to ``None``, which requires each model to have its own
                test dataloader provided.
            ckpt_path: Optional checkpoint strategy or path.
                Defaults to ``None``, which uses the best checkpoint if available,
                otherwise the last checkpoint.
            verbose: Whether to print progress messages. Defaults to ``True``.

        Raises:
            ValueError: If a trainable model has no trainer.
        """
        for i, config in enumerate(self.model_configs):
            if not config.is_trainable:
                if verbose:
                    print(
                        f"Skipping training for model {config.full_model_name()} "
                        f"[{i + 1}/{len(self.model_configs)} models] (is_trainable=False)"
                    )
                continue

            if config.trainer is None:
                raise ValueError(f"Trainer not defined for model {config.full_model_name()}.")

            if verbose:
                print(
                    f"Fit model {config.full_model_name()} "
                    f"[{i + 1}/{len(self.model_configs)} models] "
                    f"(device: {self.__device(config.trainer)}, "
                    f"log_dir: {config.trainer.log_dir}, "
                    f"ckpt_path: {ckpt_path if ckpt_path is not None else 'None'})"
                )

            train_dataloaders = (
                config.train_dataloader if config.train_dataloader is not None else train_dataloader
            )
            val_dataloaders = (
                config.val_dataloader if config.val_dataloader is not None else val_dataloader
            )
            config.trainer.fit(
                model=config.model,
                train_dataloaders=train_dataloaders,
                val_dataloaders=val_dataloaders,
                datamodule=datamodule,
                ckpt_path=ckpt_path,
            )

    def test_all(
        self,
        dataloader: DataLoader | None = None,
        datamodule: DataModule | None = None,
        ckpt_path: CkptStrategy | None = None,
        verbose: bool = True,
        verbose_loop: bool = True,
    ) -> Mapping[str, TestResult]:
        """
        Test every configured model.

        Args:
            dataloader: Shared test dataloader used when a model has no override.
                Defaults to ``None``, which requires each model to have
                its own test dataloader provided.
            datamodule: Optional Lightning datamodule. Defaults to ``None``,
                which requires each model to have its own test dataloader provided.
            ckpt_path: Optional checkpoint strategy or path. Defaults to ``None``,
                which uses the best checkpoint if available, otherwise the last checkpoint.
            verbose: Whether to print model-level progress messages. Defaults to ``True``.
            verbose_loop: Whether Lightning should print test-loop output. Defaults to ``True``.

        Returns:
            test_results: Mapping from full model name to Lightning test metrics.

        Raises:
            ValueError: If a model has no trainer available for testing.
        """
        test_results: dict[str, TestResult] = {}
        for i, config in enumerate(self.model_configs):
            test_trainer = (
                config.test_trainer if config.test_trainer is not None else config.trainer
            )
            if test_trainer is None:
                raise ValueError(f"Trainer not defined for model {config.full_model_name()}.")

            if self.__is_separate_single_process_test_trainer(config, test_trainer):
                # Ensure all processes have finished training before testing
                # to avoid warning about sync_dist=True when no distributed is happening in test
                self.__wait_for_distributed()

            if self.__should_skip_test_on_current_rank(config, test_trainer):
                test_results[config.full_model_name()] = {}
                continue

            if verbose:
                print(
                    f"Test model {config.full_model_name()} "
                    f"[{i + 1}/{len(self.model_configs)} models] "
                    f"(device: {self.__device(test_trainer)}, "
                    f"log_dir: {test_trainer.log_dir}, "
                    f"ckpt_path: {ckpt_path if ckpt_path is not None else 'None'})"
                )

            test_dataloaders = (
                config.test_dataloader if config.test_dataloader is not None else dataloader
            )
            trainer_test_results: list[TestResult] = test_trainer.test(
                model=config.model,
                dataloaders=test_dataloaders,
                datamodule=datamodule,
                ckpt_path=ckpt_path,
                verbose=verbose_loop,
            )

            # In Lightning, test() returns a list of dicts, one per dataloader,
            # but we use a single dataloader
            test_results[config.full_model_name()] = (
                trainer_test_results[0] if len(trainer_test_results) > 0 else {}
            )

        return test_results

    def finalize(self) -> None:
        """
        Finalize trainer-managed external resources.
        """
        if self.auto_wait:
            self.wait()
        if self.__tensorboard_process is not None:
            self.__tensorboard_process.terminate()
            self.__tensorboard_process = None
        self.__clear_loggers()

    def __clear_loggers(self) -> None:
        """
        Clear the internal state of all loggers.

        This is called by Lightning when a new experiment starts, so that metrics from
        previous experiments do not carry over.
        """
        if getattr(self, "_MultiModelTrainer__experiment_key", None) is None:
            return

        for config in self.model_configs:
            if config.trainer is not None and config.trainer.loggers is not None:
                for logger in config.trainer.loggers:
                    if isinstance(logger, ExperimentSharedLogger):
                        logger.clear(self.__experiment_key)

            if config.test_trainer is not None and config.test_trainer.loggers is not None:
                for logger in config.test_trainer.loggers:
                    if isinstance(logger, ExperimentSharedLogger):
                        logger.clear(self.__experiment_key)

    def wait(self) -> None:
        """
        Wait until the user presses Enter, keeping process alive.

        If no process is running, this method does nothing.
        """
        # For now, we only use this for waiting on TensorBoard, but this can be extended
        # to support waiting for other processes or conditions as needed
        if self.__tensorboard_process is None:
            return

        print(f"TensorBoard is running at http://localhost:{self.tensorboard_port}")

        try:
            input("Press Enter to stop...")
        except (KeyboardInterrupt, EOFError):
            print("Stopping TensorBoard...")

    def __auto_start_tensorboard_if_enabled(self) -> None:
        """
        Start TensorBoard when auto-start is enabled and available.
        """
        if self.auto_start_tensorboard:
            if self.__is_tensorboard_available():
                self.__tensorboard_process = self.__start_tensorboard_process()
            else:
                warnings.warn(
                    "TensorBoard is not available. "
                    "Install it with `pip install hypertorch[tensorboard]` or "
                    "`pip install tensorboard`"
                    "to enable auto-start.",
                    category=UserWarning,
                    stacklevel=2,
                )

    def __is_separate_single_process_test_trainer(
        self,
        model_config: ModelConfig,
        test_trainer: L.Trainer,
    ) -> bool:
        """
        Check whether testing uses a separate single-process trainer.

        Args:
            model_config: Model configuration being tested.
            test_trainer: Trainer selected for testing.

        Returns:
            result: ``True`` when a separate single-process test trainer is being used.
        """
        return (
            model_config.test_trainer is not None
            and test_trainer is not model_config.trainer
            and test_trainer.world_size <= 1
        )

    def __is_tensorboard_available(self) -> bool:
        """
        Check whether TensorBoard is importable.

        Returns:
            available: ``True`` when TensorBoard is installed.
        """
        return importlib.util.find_spec("tensorboard") is not None

    def __start_tensorboard_process(self) -> subprocess.Popen | None:
        """
        Start a TensorBoard subprocess for the trainer log directory.

        Returns:
            process: Started TensorBoard process, or ``None`` when startup is unavailable.
        """
        try:
            tensorboard_executable = shutil.which("tensorboard")
            if tensorboard_executable is None:
                return None

            log_dir = str(self.log_dir)
            tensorboard_port = str(self.tensorboard_port)
            process = subprocess.Popen(
                [tensorboard_executable, "--logdir", log_dir, "--port", tensorboard_port],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
            print(f"TensorBoard started at http://localhost:{tensorboard_port} (logdir={log_dir})")
            return process
        except Exception as e:
            warnings.warn(
                f"Proceeding without starting TensorBoard as it failed: {e}",
                category=UserWarning,
                stacklevel=2,
            )
            return None

    def __checkpoint_dir(
        self,
        model_config: ModelConfig,
        model_index: int,
        has_duplicate_full_model_name: bool,
        checkpoint_callback_kwargs: Mapping[str, Any],
    ) -> Path:
        """
        Resolve the checkpoint directory for a model configuration.

        Args:
            model_config: Model configuration that owns the checkpoints.
            model_index: Index of the model in ``model_configs``.
            has_duplicate_full_model_name: Whether the full model name is duplicated.
            checkpoint_callback_kwargs: Resolved ModelCheckpoint keyword arguments.

        Returns:
            checkpoint_dir: Directory for model checkpoints.
        """
        provided_dirpath: str | Path | None = checkpoint_callback_kwargs.get("dirpath")
        if provided_dirpath is not None:
            return Path(provided_dirpath)

        checkpoint_dir = (
            self.log_dir / model_config.name / f"{self.VERSION_NAME_PREFIX}_{model_config.version}"
        )

        if has_duplicate_full_model_name:
            checkpoint_dir /= f"model_{model_index}"

        return checkpoint_dir / self.DEFAULT_BASE_CHECKPOINT_DIR

    def __device(self, trainer: L.Trainer) -> str:
        """
        Return the root device string for a trainer.

        Args:
            trainer: Lightning trainer to inspect.

        Returns:
            device: Root device string, or ``"unknown"`` when unavailable.
        """
        if trainer.strategy is None:
            return self.__UNKNOWN_DEVICE
        strategy = trainer.strategy
        if strategy.root_device is None:
            return self.__UNKNOWN_DEVICE
        return str(strategy.root_device)

    def __full_model_name_counts(self, model_configs: list[ModelConfig]) -> dict[str, int]:
        """
        Count occurrences of full model names.

        Args:
            model_configs: Model configurations to inspect.

        Returns:
            counts: Mapping from full model name to occurrence count.
        """
        full_model_name_counts: dict[str, int] = {}
        for model_config in model_configs:
            full_model_name = model_config.full_model_name()
            full_model_name_counts[full_model_name] = (
                full_model_name_counts.get(full_model_name, 0) + 1
            )
        return full_model_name_counts

    def __merge_checkpoint_callback_kwargs(
        self,
        model_checkpoint_callback_kwargs: Mapping[str, Any] | None,
    ) -> dict[str, Any]:
        """
        Merge shared and per-model ModelCheckpoint keyword arguments.

        Args:
            model_checkpoint_callback_kwargs: Optional per-model keyword arguments.

        Returns:
            checkpoint_callback_kwargs: Merged shared and per-model keyword arguments.
        """
        checkpoint_callback_kwargs = copy.deepcopy(self.__shared_checkpoint_callback_kwargs)
        if model_checkpoint_callback_kwargs is not None:
            checkpoint_callback_kwargs.update(model_checkpoint_callback_kwargs)
        return checkpoint_callback_kwargs

    def __next_experiment_name(self, save_dir: Path) -> Path:
        """
        Return the next available experiment directory name.

        Args:
            save_dir: Base directory containing experiment directories.

        Returns:
            experiment_name: Next experiment directory name.
        """
        existing_experiment_names: list[str] = [
            dir.name
            for dir in save_dir.iterdir()
            if dir.is_dir() and dir.name.startswith(self.EXPERIMENT_NAME_PREFIX)
        ]
        if len(existing_experiment_names) < 1:
            return Path(
                f"{self.EXPERIMENT_NAME_PREFIX}{self.EXPERIMENT_SEPARATOR}{self.FIRST_EXPERIMENT_NUMBER}"
            )

        last_experiment_number = max(
            int(experiment_name.split(self.EXPERIMENT_SEPARATOR)[1])
            for experiment_name in existing_experiment_names
            if experiment_name.split(self.EXPERIMENT_SEPARATOR)[1].isdigit()
        )
        return Path(
            f"{self.EXPERIMENT_NAME_PREFIX}{self.EXPERIMENT_SEPARATOR}{last_experiment_number + 1}"
        )

    def __logdir(
        self,
        default_root_dir: str | Path | None,
        experiment_name: str | None,
    ) -> Path:
        """
        Resolve the log directory for this trainer instance.

        Auto-named directories are exported through an environment variable indexed by
        trainer construction order. This allows Lightning-relaunched child processes to
        reuse the parent's directory, provided they reconstruct auto-named trainers in the
        same order. It does not coordinate sibling processes started by external launchers.

        Args:
            default_root_dir: Optional root directory for logs.
            experiment_name: Optional explicit experiment directory name.

        Returns:
            log_dir: Resolved log directory.
        """
        base_dir = (
            Path(self.DEFAULT_BASE_LOG_DIR) if default_root_dir is None else Path(default_root_dir)
        )
        if experiment_name is None:
            experiment_index = next(AUTO_EXPERIMENT_INDEX)
            environment_variable = f"{AUTO_EXPERIMENT_DIR_ENV_PREFIX}_{experiment_index}"
            inherited_log_dir = os.environ.get(environment_variable)
            if inherited_log_dir is not None:
                # If a parent process has already reserved an auto-named directory, reuse it.
                return Path(inherited_log_dir)

            # Create the next experiment directory and reserve it for this trainer instance,
            # but only if it is not already reserved by a parent process. This ensures that
            # Lightning-relaunched child processes can reuse the same directory when they
            # reconstruct auto-named trainers in the same order.
            log_dir = self.__reserve_next_experiment_dir(base_dir).resolve()

            # Export the reserved directory through an environment variable for child processes
            # to inherit, in case this is a distributed training setup.
            os.environ[environment_variable] = str(log_dir)
            return log_dir

        return base_dir / experiment_name

    def __reserve_next_experiment_dir(self, base_dir: Path) -> Path:
        """
        Atomically select and reserve the next experiment directory.

        Args:
            base_dir: Base directory containing experiment directories.

        Returns:
            experiment_dir: Reserved experiment directory.
        """
        base_dir.mkdir(parents=True, exist_ok=True)

        while True:  # Until we successfully reserve a directory, keep trying
            new_experiment_name = self.__next_experiment_name(base_dir)
            new_experiment_dir = base_dir / new_experiment_name

            try:
                new_experiment_dir.mkdir(exist_ok=False)
            except FileExistsError:
                # Another trainer reserved this name after we calculated it,
                # so we need to try again to find the next available name.
                continue

            return new_experiment_dir

    def __setup_logger(
        self,
        model_config: ModelConfig,
        logger: Logger | Iterable[Logger] | bool | None,
    ) -> Logger | Iterable[Logger] | bool | None:
        """
        Resolve loggers for a model configuration.

        Args:
            model_config: Model configuration being prepared.
            logger: User-provided logger configuration.

        Returns:
            logger: User-provided logger configuration or default HyperTorch loggers.
        """
        if logger is not None:
            return logger

        loggers: list[Logger] = [
            CSVLogger(
                save_dir=self.log_dir,
                name=model_config.name,
                version=f"{self.VERSION_NAME_PREFIX}_{model_config.version}",
            ),
            MarkdownTableLogger(
                save_dir=self.log_dir,
                model_name=model_config.full_model_name(),
                experiment_name=self.__experiment_key,
            ),
            LaTexTableLogger(
                save_dir=self.log_dir,
                model_name=model_config.full_model_name(),
                experiment_name=self.__experiment_key,
                options={
                    "table_caption": "Results for Experiments",
                    "sort_by": ["des", "asc"],
                    "border": False,
                },
            ),
        ]

        if self.__is_tensorboard_available():
            from lightning.pytorch.loggers import TensorBoardLogger

            loggers.append(
                TensorBoardLogger(
                    save_dir=self.log_dir,
                    name=model_config.name,
                    version=f"{self.VERSION_NAME_PREFIX}_{model_config.version}",
                ),
            )

        return loggers

    def __setup_callbacks(
        self,
        model_config: ModelConfig,
        model_index: int,
        has_duplicate_full_model_name: bool,
        callbacks: list[Callback] | Callback | None,
        enable_checkpointing: bool,
        checkpoint_callback_kwargs: dict[str, Any],
    ) -> list[Callback] | Callback | None:
        """
        Resolve callbacks for a model configuration.

        Args:
            model_config: Model configuration being prepared.
            model_index: Index of the model in ``model_configs``.
            has_duplicate_full_model_name: Whether the full model name is duplicated.
            callbacks: User-provided callbacks.
            enable_checkpointing: Whether checkpointing is enabled.
            checkpoint_callback_kwargs: Resolved default checkpoint callback keyword arguments.

        Returns:
            callbacks: Resolved callbacks for the trainer.
        """
        model_callbacks = copy.deepcopy(callbacks)
        if not enable_checkpointing:
            return model_callbacks

        checkpoint_dir = self.__checkpoint_dir(
            model_config=model_config,
            model_index=model_index,
            has_duplicate_full_model_name=has_duplicate_full_model_name,
            checkpoint_callback_kwargs=checkpoint_callback_kwargs,
        )
        callback_list = self.__to_callback_list(model_callbacks)
        checkpoint_callbacks = [
            callback for callback in callback_list if isinstance(callback, ModelCheckpoint)
        ]

        if len(checkpoint_callbacks) < 1:
            checkpoint_callback_kwargs = copy.deepcopy(checkpoint_callback_kwargs)
            checkpoint_callback_kwargs["dirpath"] = checkpoint_dir
            callback_list.append(ModelCheckpoint(**checkpoint_callback_kwargs))
            return callback_list

        for callback in checkpoint_callbacks:
            if callback.dirpath is None:
                callback.dirpath = str(checkpoint_dir)
        return callback_list

    def __setup_trainer(self, options: Mapping[str, Any]) -> L.Trainer:
        """
        Create a Lightning Trainer with resolved shared and per-model settings.

        Args:
            options: Merged keyword arguments for the Lightning Trainer.

        Returns:
            trainer: Configured Lightning Trainer.
        """
        trainer_options = dict(options)
        trainer_options["callbacks"] = copy.deepcopy(trainer_options["callbacks"])
        return L.Trainer(**trainer_options)

    def __should_skip_test_on_current_rank(
        self,
        model_config: ModelConfig,
        test_trainer: L.Trainer,
    ) -> bool:
        """
        Check whether the current rank should skip testing.

        Args:
            model_config: Model configuration being tested.
            test_trainer: Trainer selected for testing.

        Returns:
            result: ``True`` when this process should skip the test call.
        """
        if not self.__is_separate_single_process_test_trainer(model_config, test_trainer):
            return False

        if model_config.trainer is not None:
            return not model_config.trainer.is_global_zero

        return self.distributed_global_rank != 0

    def __to_callback_list(self, callbacks: list[Callback] | Callback | None) -> list[Callback]:
        """
        Normalize callback configuration to a list.

        Args:
            callbacks: Optional callback, callback list, or ``None``.

        Returns:
            callbacks: Callback list.
        """
        if callbacks is None:
            return []
        if isinstance(callbacks, Callback):
            return [callbacks]
        return callbacks

    def __wait_for_distributed(self) -> None:
        """
        Synchronize and tear down an initialized distributed process group.
        """
        if torch.distributed.is_available() and torch.distributed.is_initialized():
            torch.distributed.barrier()
            torch.distributed.destroy_process_group()

distributed_global_rank property

Return the global rank of the current process.

Returns:

Name Type Description
global_rank int

The global rank of the current process.

is_global_zero property

Return whether the current process is the global zero process.

Check first if any of the trainers is the global zero, which covers the case where a custom strategy that handles distributed differently was provided. If no trainer is available, fall back to checking the global rank.

Returns:

Name Type Description
is_global_zero bool

True if the current process is the global zero process, False otherwise.

models property

Return all configured Lightning modules.

Returns:

Name Type Description
models list[LightningModule]

List of configured models.

__init__(model_configs, experiment_name=None, accelerator='auto', devices='auto', test_devices=None, strategy='auto', num_nodes=1, precision=None, max_epochs=None, min_epochs=None, max_steps=-1, min_steps=None, check_val_every_n_epoch=1, logger=None, default_root_dir=None, enable_autolog_hparams=True, log_every_n_steps=None, profiler=None, fast_dev_run=False, enable_checkpointing=True, enable_progress_bar=True, enable_model_summary=None, callbacks=None, checkpoint_callback_kwargs=None, auto_start_tensorboard=False, tensorboard_port=6006, auto_wait=False, **kwargs)

Initialize trainers for one or more model configurations.

Parameters:

Name Type Description Default
model_configs list[ModelConfig]

A list of ModelConfig objects, each containing a model and its associated trainer (if any).

required
experiment_name str | None

Name for this experiment run's log directory. When None (default), auto-increments as experiment_0, experiment_1, etc. under the log root directory. Lightning-launched child processes on the same machine inherit the selected directory. For external launchers such as torchrun, provide the same explicit name on every rank or set HYPERTORCH_AUTO_EXPERIMENT_DIR_<index> before launch.

None
accelerator str | Accelerator

Supports passing different accelerator types ("cpu", "gpu", "tpu", "hpu", "mps", "auto") as well as custom accelerator instances.

'auto'
devices list[int] | str | int

The devices to use. Can be set to a positive number (int or str), a sequence of device indices (list or str), the value -1 to indicate all available devices should be used, or "auto" for automatic selection based on the chosen accelerator. Defaults to "auto".

'auto'
test_devices list[int] | str | int | None

Optional device configuration for automatically-created test trainers. When set, test_all uses a separate Trainer with the same Trainer parameters as training except for devices. This is useful for running distributed training but single-device testing, e.g. test_devices=1. Defaults to None, which makes testing use the fit trainer unless a ModelConfig.test_trainer is provided.

None
strategy str | Strategy

Supports different training strategies with aliases as well custom strategies. Defaults to "auto".

'auto'
num_nodes int

Number of GPU nodes for distributed training. Defaults to 1.

1
precision Any | None

Double precision (64, '64' or '64-true'), full precision (32, '32' or '32-true'), 16bit mixed precision (16, '16', '16-mixed') or bfloat16 mixed precision ('bf16', 'bf16-mixed'). Can be used on CPU, GPU, TPUs, or HPUs. Defaults to '32-true'.

None
max_epochs int | None

Stop training once this number of epochs is reached. Disabled by default (None). If both max_epochs and max_steps are not specified, defaults to max_epochs = 1000. To enable infinite training, set max_epochs = -1.

None
min_epochs int | None

Force training for at least these many epochs. Disabled by default (None).

None
max_steps int

Stop training after this number of steps. Disabled by default (-1). If max_steps = -1 and max_epochs = None, will default to max_epochs = 1000. To enable infinite training, set max_epochs to -1.

-1
min_steps int | None

Force training for at least these number of steps. Disabled by default (None).

None
check_val_every_n_epoch int | None

Perform a validation loop after every N training epochs. If None, validation will be done solely based on the number of training batches, requiring val_check_interval to be an integer value. When used together with a time-based val_check_interval and check_val_every_n_epoch > 1, validation is aligned to epoch multiples: if the interval elapses before the next multiple-N epoch, validation runs at the start of that epoch (after the first batch) and the timer resets; if it elapses during a multiple-N epoch, validation runs after the current batch. For None or 1 cases, the time-based behavior of val_check_interval applies without additional alignment. Defaults to 1.

1
logger Logger | Iterable[Logger] | bool | None

Logger (or iterable collection of loggers) for experiment tracking. A True value uses the default TensorBoardLogger if it is installed, otherwise CSVLogger. False will disable logging. If multiple loggers are provided, local files (checkpoints, profiler traces, etc.) are saved in the log_dir of the first logger. Defaults to True.

None
default_root_dir str | Path | None

Default path for logs and weights when no logger/ckpt_callback passed. Defaults to os.getcwd(). Can be remote file paths such as s3://mybucket/path or 'hdfs://path/'

None
enable_autolog_hparams bool

Whether to log hyperparameters at the start of a run. Defaults to True.

True
log_every_n_steps int | None

How often to log within steps. Defaults to 50.

None
profiler Profiler | str | None

To profile individual steps during training and assist in identifying bottlenecks. Defaults to None.

None
fast_dev_run int | bool

Runs n if set to n (int) else 1 if set to True batch(es) of train, val and test to find any bugs (ie: a sort of unit test). Defaults to False.

False
enable_checkpointing bool

If True, enable checkpointing. It will configure a default ModelCheckpoint callback if there is no user-defined ModelCheckpoint in :paramref:~hypertorch.train.MultiModelTrainer.callbacks. Defaults to True.

True
enable_progress_bar bool

Whether to enable the progress bar by default. Defaults to True.

True
enable_model_summary bool | None

Whether to enable model summarization by default. Defaults to True.

None
callbacks list[Callback] | Callback | None

Add a callback or list of callbacks. Defaults to None.

None
checkpoint_callback_kwargs dict[str, Any] | None

Keyword arguments passed to the default ModelCheckpoint callback when checkpointing is enabled and no user-defined ModelCheckpoint is provided. Pass dirpath to override the default checkpoint directory. Defaults to None.

None
auto_start_tensorboard bool

When True and tensorboard is installed, automatically starts a TensorBoard server pointing at the experiment log directory. Using this option requires that TensorBoard is installed in the environment and moves control of the TensorBoard server lifecycle to the trainer, which will automatically terminate the server when the trainer is finalized (e.g., at the end of a with block or when the object is garbage collected). Enable auto_wait to keep the server alive after training completes so you can inspect results before the trainer is finalized. Defaults to False.

False
tensorboard_port int

Port for the auto-launched TensorBoard server. Defaults to 6006.

6006
auto_wait bool

When True and a TensorBoard server is running, automatically calls wait inside finalize before terminating the server, so the user can inspect results before the process is stopped. Defaults to False.

False
kwargs Any

max_time: Maximum wall-clock training time. Passed to Lightning's Trainer. limit_train_batches: Fraction or number of training batches to use. limit_val_batches: Fraction or number of validation batches to use. limit_test_batches: Fraction or number of test batches to use. limit_predict_batches: Fraction or number of prediction batches to use. overfit_batches: Fraction or number of batches to use for overfitting checks. val_check_interval: How often to run validation within a training epoch. num_sanity_val_steps: Number of validation batches to run before training starts. accumulate_grad_batches: Number of batches over which to accumulate gradients. gradient_clip_val: Value used for gradient clipping. gradient_clip_algorithm: Gradient clipping algorithm to use. deterministic: Whether to enable deterministic operations, or "warn" to warn when deterministic execution is not available. benchmark: Whether to enable cuDNN benchmarking. inference_mode: Whether validation, test, and prediction loops should use torch.inference_mode. use_distributed_sampler: Whether Lightning should inject distributed samplers for distributed training. detect_anomaly: Whether to enable PyTorch autograd anomaly detection. barebones: Whether to disable features that may affect raw training-loop speed. plugins: Lightning plugins or list of plugins to use. sync_batchnorm: Whether to synchronize batch normalization across devices. reload_dataloaders_every_n_epochs: How often to recreate dataloaders. model_registry: Name of the Lightning model registry to use.

{}

Raises:

Type Description
ValueError

If no model configurations are provided or numeric settings are invalid.

Source code in hypertorch/train/trainer.py
def __init__(
    self,
    model_configs: list[ModelConfig],
    experiment_name: str | None = None,
    # args to pass to each Trainer
    accelerator: str | Accelerator = "auto",
    devices: list[int] | str | int = "auto",
    test_devices: list[int] | str | int | None = None,
    strategy: str | Strategy = "auto",
    num_nodes: int = 1,
    precision: Any
    | None = None,  # Any as Lightning accepts multiple types (int, str, Literal, etc.)
    max_epochs: int | None = None,
    min_epochs: int | None = None,
    max_steps: int = -1,
    min_steps: int | None = None,
    check_val_every_n_epoch: int | None = 1,
    logger: Logger | Iterable[Logger] | bool | None = None,
    default_root_dir: str | Path | None = None,
    enable_autolog_hparams: bool = True,
    log_every_n_steps: int | None = None,
    profiler: Profiler | str | None = None,
    fast_dev_run: int | bool = False,
    enable_checkpointing: bool = True,
    enable_progress_bar: bool = True,
    enable_model_summary: bool | None = None,
    callbacks: list[Callback] | Callback | None = None,
    checkpoint_callback_kwargs: dict[str, Any] | None = None,
    auto_start_tensorboard: bool = False,
    tensorboard_port: int = 6006,
    auto_wait: bool = False,
    **kwargs: Any,
) -> None:
    """
    Initialize trainers for one or more model configurations.

    Args:
        model_configs: A list of ModelConfig objects, each containing a model and its
            associated trainer (if any).
        experiment_name: Name for this experiment run's log directory. When ``None`` (default),
            auto-increments as ``experiment_0``, ``experiment_1``, etc. under
            the log root directory. Lightning-launched child processes on the same machine
            inherit the selected directory. For external launchers such as ``torchrun``,
            provide the same explicit name on every rank or set
            ``HYPERTORCH_AUTO_EXPERIMENT_DIR_<index>`` before launch.
        accelerator: Supports passing different accelerator types
            ("cpu", "gpu", "tpu", "hpu", "mps", "auto") as well as custom accelerator instances.
        devices: The devices to use. Can be set to a positive number (int or str), a
            sequence of device indices (list or str), the value ``-1`` to indicate all available
            devices should be used, or ``"auto"`` for automatic selection based on the chosen
            accelerator. Defaults to ``"auto"``.
        test_devices: Optional device configuration for automatically-created test trainers.
            When set, ``test_all`` uses a separate Trainer with the same Trainer parameters as
            training except for ``devices``. This is useful for running distributed training
            but single-device testing, e.g. ``test_devices=1``. Defaults to ``None``,
            which makes testing use the fit trainer unless a
            ``ModelConfig.test_trainer`` is provided.
        strategy: Supports different training strategies with aliases as well custom strategies.
            Defaults to ``"auto"``.
        num_nodes: Number of GPU nodes for distributed training. Defaults to ``1``.
        precision: Double precision (64, '64' or '64-true'),
            full precision (32, '32' or '32-true'), 16bit mixed precision (16, '16', '16-mixed')
            or bfloat16 mixed precision ('bf16', 'bf16-mixed').
            Can be used on CPU, GPU, TPUs, or HPUs. Defaults to ``'32-true'``.
        max_epochs: Stop training once this number of epochs is reached.
            Disabled by default (None). If both max_epochs and max_steps are not specified,
            defaults to ``max_epochs = 1000``. To enable infinite training,
            set ``max_epochs = -1``.
        min_epochs: Force training for at least these many epochs. Disabled by default (None).
        max_steps: Stop training after this number of steps. Disabled by default (-1).
            If ``max_steps = -1`` and ``max_epochs = None``, will default to
            ``max_epochs = 1000``. To enable infinite training, set ``max_epochs`` to ``-1``.
        min_steps: Force training for at least these number of steps.
            Disabled by default (``None``).
        check_val_every_n_epoch: Perform a validation loop after every `N` training epochs.
            If ``None``, validation will be done solely based on the number of training batches,
            requiring ``val_check_interval`` to be an integer value. When used together with a
            time-based ``val_check_interval`` and ``check_val_every_n_epoch`` > 1, validation is
            aligned to epoch multiples: if the interval elapses before the next multiple-N
            epoch, validation runs at the start of that epoch (after the first batch) and the
            timer resets; if it elapses during a multiple-N epoch, validation runs after the
            current batch. For ``None`` or ``1`` cases, the time-based behavior of
            ``val_check_interval`` applies without additional alignment. Defaults to ``1``.
        logger: Logger (or iterable collection of loggers) for experiment tracking. A ``True``
            value uses the default ``TensorBoardLogger`` if it is installed,
            otherwise ``CSVLogger``. ``False`` will disable logging. If multiple loggers are
            provided, local files (checkpoints, profiler traces, etc.) are saved in the
            ``log_dir`` of the first logger. Defaults to ``True``.
        default_root_dir: Default path for logs and weights when no logger/ckpt_callback passed.
            Defaults to ``os.getcwd()``.
            Can be remote file paths such as `s3://mybucket/path` or 'hdfs://path/'
        enable_autolog_hparams: Whether to log hyperparameters at the start of a run.
            Defaults to ``True``.
        log_every_n_steps: How often to log within steps.
            Defaults to ``50``.
        profiler: To profile individual steps during training and assist in
            identifying bottlenecks. Defaults to ``None``.
        fast_dev_run: Runs n if set to ``n`` (int) else 1 if set to ``True`` batch(es)
            of train, val and test to find any bugs (ie: a sort of unit test).
            Defaults to ``False``.
        enable_checkpointing: If ``True``, enable checkpointing.
            It will configure a default ModelCheckpoint callback if there is no user-defined
                ModelCheckpoint in :paramref:`~hypertorch.train.MultiModelTrainer.callbacks`.
            Defaults to ``True``.
        enable_progress_bar: Whether to enable the progress bar by default.
            Defaults to ``True``.
        enable_model_summary: Whether to enable model summarization by default.
            Defaults to ``True``.
        callbacks: Add a callback or list of callbacks.
            Defaults to ``None``.
        checkpoint_callback_kwargs: Keyword arguments passed to the default
            ``ModelCheckpoint`` callback when checkpointing is enabled and no
            user-defined ``ModelCheckpoint`` is provided. Pass ``dirpath`` to
            override the default checkpoint directory.
            Defaults to ``None``.
        auto_start_tensorboard: When ``True`` and tensorboard is installed, automatically
            starts a TensorBoard server pointing at the experiment log directory.
            Using this option requires that TensorBoard is installed in the environment and
            moves control of the TensorBoard server lifecycle to the trainer, which will
            automatically terminate the server when the trainer is finalized
            (e.g., at the end of a ``with`` block or when the object is garbage collected).
            Enable `auto_wait` to keep the server alive after training completes so you can
            inspect results before the trainer is finalized. Defaults to ``False``.
        tensorboard_port: Port for the auto-launched TensorBoard server.
            Defaults to ``6006``.
        auto_wait: When ``True`` and a TensorBoard server is running, automatically calls
            ``wait`` inside `finalize` before terminating the server, so the user
            can inspect results before the process is stopped.
            Defaults to ``False``.

        kwargs:
            max_time: Maximum wall-clock training time. Passed to Lightning's ``Trainer``.
            limit_train_batches: Fraction or number of training batches to use.
            limit_val_batches: Fraction or number of validation batches to use.
            limit_test_batches: Fraction or number of test batches to use.
            limit_predict_batches: Fraction or number of prediction batches to use.
            overfit_batches: Fraction or number of batches to use for overfitting checks.
            val_check_interval: How often to run validation within a training epoch.
            num_sanity_val_steps: Number of validation batches to run before training starts.
            accumulate_grad_batches: Number of batches over which to accumulate gradients.
            gradient_clip_val: Value used for gradient clipping.
            gradient_clip_algorithm: Gradient clipping algorithm to use.
            deterministic: Whether to enable deterministic operations, or ``"warn"``
                to warn when deterministic execution is not available.
            benchmark: Whether to enable cuDNN benchmarking.
            inference_mode: Whether validation, test, and prediction loops should use
                ``torch.inference_mode``.
            use_distributed_sampler: Whether Lightning should inject distributed samplers for
                distributed training.
            detect_anomaly: Whether to enable PyTorch autograd anomaly detection.
            barebones: Whether to disable features that may affect raw training-loop speed.
            plugins: Lightning plugins or list of plugins to use.
            sync_batchnorm: Whether to synchronize batch normalization across devices.
            reload_dataloaders_every_n_epochs: How often to recreate dataloaders.
            model_registry: Name of the Lightning model registry to use.

    Raises:
        ValueError: If no model configurations are provided or numeric settings are invalid.
    """
    self.auto_wait: bool = auto_wait
    self.__tensorboard_process: subprocess.Popen | None = None
    validate_is_non_negative("tensorboard_port", tensorboard_port)

    self.model_configs: list[ModelConfig] = model_configs
    validate_is_non_empty("model_configs", self.model_configs)

    self.log_dir: Path = self.__logdir(default_root_dir, experiment_name)
    self.__experiment_key = str(self.log_dir)

    self.auto_start_tensorboard: bool = auto_start_tensorboard
    self.tensorboard_port: int = tensorboard_port
    self.__shared_checkpoint_callback_kwargs: dict[str, Any] = (
        checkpoint_callback_kwargs if checkpoint_callback_kwargs is not None else {}
    )

    shared_trainer_options: dict[str, Any] = {
        "accelerator": accelerator,
        "devices": devices,
        "strategy": strategy,
        "num_nodes": num_nodes,
        "precision": precision,
        "max_epochs": max_epochs,
        "min_epochs": min_epochs,
        "max_steps": max_steps,
        "min_steps": min_steps,
        "check_val_every_n_epoch": check_val_every_n_epoch,
        "logger": logger,
        "default_root_dir": default_root_dir,
        "enable_autolog_hparams": enable_autolog_hparams,
        "log_every_n_steps": log_every_n_steps,
        "profiler": profiler,
        "fast_dev_run": fast_dev_run,
        "enable_checkpointing": enable_checkpointing,
        "enable_progress_bar": enable_progress_bar,
        "enable_model_summary": enable_model_summary,
        "callbacks": callbacks,
        **kwargs,
    }

    full_model_name_counts = self.__full_model_name_counts(model_configs)
    for model_index, model_config in enumerate(model_configs):
        model_options = dict(model_config.trainer_kwargs or {})
        model_test_devices = model_options.pop("test_devices", test_devices)
        model_checkpoint_callback_kwargs = model_options.pop(
            "checkpoint_callback_kwargs",
            None,
        )

        should_create_trainer = model_config.trainer is None
        should_create_test_trainer = (
            model_config.test_trainer is None and model_test_devices is not None
        )
        should_create_at_least_one_trainer = should_create_trainer or should_create_test_trainer

        if should_create_at_least_one_trainer:
            has_duplicate_full_model_name = (
                full_model_name_counts[model_config.full_model_name()] > 1
            )

            trainer_options = {**shared_trainer_options, **model_options}
            trainer_options["logger"] = self.__setup_logger(
                model_config=model_config,
                logger=cast(
                    Logger | Iterable[Logger] | bool | None,
                    trainer_options["logger"],
                ),
            )
            trainer_options["callbacks"] = self.__setup_callbacks(
                model_config=model_config,
                model_index=model_index,
                has_duplicate_full_model_name=has_duplicate_full_model_name,
                callbacks=cast(
                    list[Callback] | Callback | None,
                    trainer_options["callbacks"],
                ),
                enable_checkpointing=cast(bool, trainer_options["enable_checkpointing"]),
                checkpoint_callback_kwargs=self.__merge_checkpoint_callback_kwargs(
                    model_checkpoint_callback_kwargs
                ),
            )

            if should_create_trainer:
                model_config.trainer = self.__setup_trainer(trainer_options)

            if should_create_test_trainer:
                test_trainer_options = {**trainer_options, "devices": model_test_devices}
                model_config.test_trainer = self.__setup_trainer(test_trainer_options)

    print(f"Initialized trainer(models: {len(model_configs)}, log_dir: {self.log_dir})")
    self.__auto_start_tensorboard_if_enabled()

__enter__()

Enter the trainer context manager.

Returns:

Name Type Description
trainer MultiModelTrainer

This trainer instance.

Source code in hypertorch/train/trainer.py
def __enter__(self) -> MultiModelTrainer:
    """
    Enter the trainer context manager.

    Returns:
        trainer: This trainer instance.
    """
    return self

__exit__(exc_type, exc_val, exc_tb)

Exit the trainer context manager and finalize resources.

Parameters:

Name Type Description Default
exc_type type[BaseException] | None

Exception type from the context, if any, unused.

required
exc_val BaseException | None

Exception value from the context, if any, unused.

required
exc_tb Any

Exception traceback from the context, if any, unused.

required
Source code in hypertorch/train/trainer.py
def __exit__(
    self,
    exc_type: type[BaseException] | None,
    exc_val: BaseException | None,
    exc_tb: Any,
) -> None:
    """
    Exit the trainer context manager and finalize resources.

    Args:
        exc_type: Exception type from the context, if any, unused.
        exc_val: Exception value from the context, if any, unused.
        exc_tb: Exception traceback from the context, if any, unused.
    """
    self.finalize()

__del__()

Finalize external resources during object cleanup.

Source code in hypertorch/train/trainer.py
def __del__(self) -> None:
    """
    Finalize external resources during object cleanup.
    """
    try:
        self.finalize()
    except Exception as e:
        warnings.warn(
            f"Exception occurred during {self.__class__.__name__} cleanup. Error: {e}",
            category=UserWarning,
            stacklevel=2,
        )

model(name, version='default')

Return a configured model by name and version.

Parameters:

Name Type Description Default
name str

Model name.

required
version str

Model version. Defaults to "default".

'default'

Returns:

Name Type Description
model LightningModule | None

Matching model, or None when no model matches.

Source code in hypertorch/train/trainer.py
def model(self, name: str, version: str = "default") -> L.LightningModule | None:
    """
    Return a configured model by name and version.

    Args:
        name: Model name.
        version: Model version. Defaults to ``"default"``.

    Returns:
        model: Matching model, or ``None`` when no model matches.
    """
    for config in self.model_configs:
        if config.name == name and config.version == version:
            return config.model
    return None

fit_all(train_dataloader=None, val_dataloader=None, datamodule=None, ckpt_path=None, verbose=True)

Fit every trainable configured model.

Parameters:

Name Type Description Default
train_dataloader DataLoader | None

Shared training dataloader used when a model has no override. Defaults to None, which requires each model to have its own training dataloader provided.

None
val_dataloader DataLoader | None

Shared validation dataloader used when a model has no override. Defaults to None, which requires each model to have its own validation dataloader provided.

None
datamodule DataModule | None

Optional Lightning datamodule. Defaults to None, which requires each model to have its own test dataloader provided.

None
ckpt_path CkptStrategy | None

Optional checkpoint strategy or path. Defaults to None, which uses the best checkpoint if available, otherwise the last checkpoint.

None
verbose bool

Whether to print progress messages. Defaults to True.

True

Raises:

Type Description
ValueError

If a trainable model has no trainer.

Source code in hypertorch/train/trainer.py
def fit_all(
    self,
    train_dataloader: DataLoader | None = None,
    val_dataloader: DataLoader | None = None,
    datamodule: DataModule | None = None,
    ckpt_path: CkptStrategy | None = None,
    verbose: bool = True,
) -> None:
    """
    Fit every trainable configured model.

    Args:
        train_dataloader: Shared training dataloader used when a model has no override.
            Defaults to ``None``, which requires each model to have its own
            training dataloader provided.
        val_dataloader: Shared validation dataloader used when a model has no override.
            Defaults to ``None``, which requires each model to have its own
            validation dataloader provided.
        datamodule: Optional Lightning datamodule.
            Defaults to ``None``, which requires each model to have its own
            test dataloader provided.
        ckpt_path: Optional checkpoint strategy or path.
            Defaults to ``None``, which uses the best checkpoint if available,
            otherwise the last checkpoint.
        verbose: Whether to print progress messages. Defaults to ``True``.

    Raises:
        ValueError: If a trainable model has no trainer.
    """
    for i, config in enumerate(self.model_configs):
        if not config.is_trainable:
            if verbose:
                print(
                    f"Skipping training for model {config.full_model_name()} "
                    f"[{i + 1}/{len(self.model_configs)} models] (is_trainable=False)"
                )
            continue

        if config.trainer is None:
            raise ValueError(f"Trainer not defined for model {config.full_model_name()}.")

        if verbose:
            print(
                f"Fit model {config.full_model_name()} "
                f"[{i + 1}/{len(self.model_configs)} models] "
                f"(device: {self.__device(config.trainer)}, "
                f"log_dir: {config.trainer.log_dir}, "
                f"ckpt_path: {ckpt_path if ckpt_path is not None else 'None'})"
            )

        train_dataloaders = (
            config.train_dataloader if config.train_dataloader is not None else train_dataloader
        )
        val_dataloaders = (
            config.val_dataloader if config.val_dataloader is not None else val_dataloader
        )
        config.trainer.fit(
            model=config.model,
            train_dataloaders=train_dataloaders,
            val_dataloaders=val_dataloaders,
            datamodule=datamodule,
            ckpt_path=ckpt_path,
        )

test_all(dataloader=None, datamodule=None, ckpt_path=None, verbose=True, verbose_loop=True)

Test every configured model.

Parameters:

Name Type Description Default
dataloader DataLoader | None

Shared test dataloader used when a model has no override. Defaults to None, which requires each model to have its own test dataloader provided.

None
datamodule DataModule | None

Optional Lightning datamodule. Defaults to None, which requires each model to have its own test dataloader provided.

None
ckpt_path CkptStrategy | None

Optional checkpoint strategy or path. Defaults to None, which uses the best checkpoint if available, otherwise the last checkpoint.

None
verbose bool

Whether to print model-level progress messages. Defaults to True.

True
verbose_loop bool

Whether Lightning should print test-loop output. Defaults to True.

True

Returns:

Name Type Description
test_results Mapping[str, TestResult]

Mapping from full model name to Lightning test metrics.

Raises:

Type Description
ValueError

If a model has no trainer available for testing.

Source code in hypertorch/train/trainer.py
def test_all(
    self,
    dataloader: DataLoader | None = None,
    datamodule: DataModule | None = None,
    ckpt_path: CkptStrategy | None = None,
    verbose: bool = True,
    verbose_loop: bool = True,
) -> Mapping[str, TestResult]:
    """
    Test every configured model.

    Args:
        dataloader: Shared test dataloader used when a model has no override.
            Defaults to ``None``, which requires each model to have
            its own test dataloader provided.
        datamodule: Optional Lightning datamodule. Defaults to ``None``,
            which requires each model to have its own test dataloader provided.
        ckpt_path: Optional checkpoint strategy or path. Defaults to ``None``,
            which uses the best checkpoint if available, otherwise the last checkpoint.
        verbose: Whether to print model-level progress messages. Defaults to ``True``.
        verbose_loop: Whether Lightning should print test-loop output. Defaults to ``True``.

    Returns:
        test_results: Mapping from full model name to Lightning test metrics.

    Raises:
        ValueError: If a model has no trainer available for testing.
    """
    test_results: dict[str, TestResult] = {}
    for i, config in enumerate(self.model_configs):
        test_trainer = (
            config.test_trainer if config.test_trainer is not None else config.trainer
        )
        if test_trainer is None:
            raise ValueError(f"Trainer not defined for model {config.full_model_name()}.")

        if self.__is_separate_single_process_test_trainer(config, test_trainer):
            # Ensure all processes have finished training before testing
            # to avoid warning about sync_dist=True when no distributed is happening in test
            self.__wait_for_distributed()

        if self.__should_skip_test_on_current_rank(config, test_trainer):
            test_results[config.full_model_name()] = {}
            continue

        if verbose:
            print(
                f"Test model {config.full_model_name()} "
                f"[{i + 1}/{len(self.model_configs)} models] "
                f"(device: {self.__device(test_trainer)}, "
                f"log_dir: {test_trainer.log_dir}, "
                f"ckpt_path: {ckpt_path if ckpt_path is not None else 'None'})"
            )

        test_dataloaders = (
            config.test_dataloader if config.test_dataloader is not None else dataloader
        )
        trainer_test_results: list[TestResult] = test_trainer.test(
            model=config.model,
            dataloaders=test_dataloaders,
            datamodule=datamodule,
            ckpt_path=ckpt_path,
            verbose=verbose_loop,
        )

        # In Lightning, test() returns a list of dicts, one per dataloader,
        # but we use a single dataloader
        test_results[config.full_model_name()] = (
            trainer_test_results[0] if len(trainer_test_results) > 0 else {}
        )

    return test_results

finalize()

Finalize trainer-managed external resources.

Source code in hypertorch/train/trainer.py
def finalize(self) -> None:
    """
    Finalize trainer-managed external resources.
    """
    if self.auto_wait:
        self.wait()
    if self.__tensorboard_process is not None:
        self.__tensorboard_process.terminate()
        self.__tensorboard_process = None
    self.__clear_loggers()

__clear_loggers()

Clear the internal state of all loggers.

This is called by Lightning when a new experiment starts, so that metrics from previous experiments do not carry over.

Source code in hypertorch/train/trainer.py
def __clear_loggers(self) -> None:
    """
    Clear the internal state of all loggers.

    This is called by Lightning when a new experiment starts, so that metrics from
    previous experiments do not carry over.
    """
    if getattr(self, "_MultiModelTrainer__experiment_key", None) is None:
        return

    for config in self.model_configs:
        if config.trainer is not None and config.trainer.loggers is not None:
            for logger in config.trainer.loggers:
                if isinstance(logger, ExperimentSharedLogger):
                    logger.clear(self.__experiment_key)

        if config.test_trainer is not None and config.test_trainer.loggers is not None:
            for logger in config.test_trainer.loggers:
                if isinstance(logger, ExperimentSharedLogger):
                    logger.clear(self.__experiment_key)

wait()

Wait until the user presses Enter, keeping process alive.

If no process is running, this method does nothing.

Source code in hypertorch/train/trainer.py
def wait(self) -> None:
    """
    Wait until the user presses Enter, keeping process alive.

    If no process is running, this method does nothing.
    """
    # For now, we only use this for waiting on TensorBoard, but this can be extended
    # to support waiting for other processes or conditions as needed
    if self.__tensorboard_process is None:
        return

    print(f"TensorBoard is running at http://localhost:{self.tensorboard_port}")

    try:
        input("Press Enter to stop...")
    except (KeyboardInterrupt, EOFError):
        print("Stopping TensorBoard...")

__auto_start_tensorboard_if_enabled()

Start TensorBoard when auto-start is enabled and available.

Source code in hypertorch/train/trainer.py
def __auto_start_tensorboard_if_enabled(self) -> None:
    """
    Start TensorBoard when auto-start is enabled and available.
    """
    if self.auto_start_tensorboard:
        if self.__is_tensorboard_available():
            self.__tensorboard_process = self.__start_tensorboard_process()
        else:
            warnings.warn(
                "TensorBoard is not available. "
                "Install it with `pip install hypertorch[tensorboard]` or "
                "`pip install tensorboard`"
                "to enable auto-start.",
                category=UserWarning,
                stacklevel=2,
            )

__is_separate_single_process_test_trainer(model_config, test_trainer)

Check whether testing uses a separate single-process trainer.

Parameters:

Name Type Description Default
model_config ModelConfig

Model configuration being tested.

required
test_trainer Trainer

Trainer selected for testing.

required

Returns:

Name Type Description
result bool

True when a separate single-process test trainer is being used.

Source code in hypertorch/train/trainer.py
def __is_separate_single_process_test_trainer(
    self,
    model_config: ModelConfig,
    test_trainer: L.Trainer,
) -> bool:
    """
    Check whether testing uses a separate single-process trainer.

    Args:
        model_config: Model configuration being tested.
        test_trainer: Trainer selected for testing.

    Returns:
        result: ``True`` when a separate single-process test trainer is being used.
    """
    return (
        model_config.test_trainer is not None
        and test_trainer is not model_config.trainer
        and test_trainer.world_size <= 1
    )

__is_tensorboard_available()

Check whether TensorBoard is importable.

Returns:

Name Type Description
available bool

True when TensorBoard is installed.

Source code in hypertorch/train/trainer.py
def __is_tensorboard_available(self) -> bool:
    """
    Check whether TensorBoard is importable.

    Returns:
        available: ``True`` when TensorBoard is installed.
    """
    return importlib.util.find_spec("tensorboard") is not None

__start_tensorboard_process()

Start a TensorBoard subprocess for the trainer log directory.

Returns:

Name Type Description
process Popen | None

Started TensorBoard process, or None when startup is unavailable.

Source code in hypertorch/train/trainer.py
def __start_tensorboard_process(self) -> subprocess.Popen | None:
    """
    Start a TensorBoard subprocess for the trainer log directory.

    Returns:
        process: Started TensorBoard process, or ``None`` when startup is unavailable.
    """
    try:
        tensorboard_executable = shutil.which("tensorboard")
        if tensorboard_executable is None:
            return None

        log_dir = str(self.log_dir)
        tensorboard_port = str(self.tensorboard_port)
        process = subprocess.Popen(
            [tensorboard_executable, "--logdir", log_dir, "--port", tensorboard_port],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )
        print(f"TensorBoard started at http://localhost:{tensorboard_port} (logdir={log_dir})")
        return process
    except Exception as e:
        warnings.warn(
            f"Proceeding without starting TensorBoard as it failed: {e}",
            category=UserWarning,
            stacklevel=2,
        )
        return None

__checkpoint_dir(model_config, model_index, has_duplicate_full_model_name, checkpoint_callback_kwargs)

Resolve the checkpoint directory for a model configuration.

Parameters:

Name Type Description Default
model_config ModelConfig

Model configuration that owns the checkpoints.

required
model_index int

Index of the model in model_configs.

required
has_duplicate_full_model_name bool

Whether the full model name is duplicated.

required
checkpoint_callback_kwargs Mapping[str, Any]

Resolved ModelCheckpoint keyword arguments.

required

Returns:

Name Type Description
checkpoint_dir Path

Directory for model checkpoints.

Source code in hypertorch/train/trainer.py
def __checkpoint_dir(
    self,
    model_config: ModelConfig,
    model_index: int,
    has_duplicate_full_model_name: bool,
    checkpoint_callback_kwargs: Mapping[str, Any],
) -> Path:
    """
    Resolve the checkpoint directory for a model configuration.

    Args:
        model_config: Model configuration that owns the checkpoints.
        model_index: Index of the model in ``model_configs``.
        has_duplicate_full_model_name: Whether the full model name is duplicated.
        checkpoint_callback_kwargs: Resolved ModelCheckpoint keyword arguments.

    Returns:
        checkpoint_dir: Directory for model checkpoints.
    """
    provided_dirpath: str | Path | None = checkpoint_callback_kwargs.get("dirpath")
    if provided_dirpath is not None:
        return Path(provided_dirpath)

    checkpoint_dir = (
        self.log_dir / model_config.name / f"{self.VERSION_NAME_PREFIX}_{model_config.version}"
    )

    if has_duplicate_full_model_name:
        checkpoint_dir /= f"model_{model_index}"

    return checkpoint_dir / self.DEFAULT_BASE_CHECKPOINT_DIR

__device(trainer)

Return the root device string for a trainer.

Parameters:

Name Type Description Default
trainer Trainer

Lightning trainer to inspect.

required

Returns:

Name Type Description
device str

Root device string, or "unknown" when unavailable.

Source code in hypertorch/train/trainer.py
def __device(self, trainer: L.Trainer) -> str:
    """
    Return the root device string for a trainer.

    Args:
        trainer: Lightning trainer to inspect.

    Returns:
        device: Root device string, or ``"unknown"`` when unavailable.
    """
    if trainer.strategy is None:
        return self.__UNKNOWN_DEVICE
    strategy = trainer.strategy
    if strategy.root_device is None:
        return self.__UNKNOWN_DEVICE
    return str(strategy.root_device)

__full_model_name_counts(model_configs)

Count occurrences of full model names.

Parameters:

Name Type Description Default
model_configs list[ModelConfig]

Model configurations to inspect.

required

Returns:

Name Type Description
counts dict[str, int]

Mapping from full model name to occurrence count.

Source code in hypertorch/train/trainer.py
def __full_model_name_counts(self, model_configs: list[ModelConfig]) -> dict[str, int]:
    """
    Count occurrences of full model names.

    Args:
        model_configs: Model configurations to inspect.

    Returns:
        counts: Mapping from full model name to occurrence count.
    """
    full_model_name_counts: dict[str, int] = {}
    for model_config in model_configs:
        full_model_name = model_config.full_model_name()
        full_model_name_counts[full_model_name] = (
            full_model_name_counts.get(full_model_name, 0) + 1
        )
    return full_model_name_counts

__merge_checkpoint_callback_kwargs(model_checkpoint_callback_kwargs)

Merge shared and per-model ModelCheckpoint keyword arguments.

Parameters:

Name Type Description Default
model_checkpoint_callback_kwargs Mapping[str, Any] | None

Optional per-model keyword arguments.

required

Returns:

Name Type Description
checkpoint_callback_kwargs dict[str, Any]

Merged shared and per-model keyword arguments.

Source code in hypertorch/train/trainer.py
def __merge_checkpoint_callback_kwargs(
    self,
    model_checkpoint_callback_kwargs: Mapping[str, Any] | None,
) -> dict[str, Any]:
    """
    Merge shared and per-model ModelCheckpoint keyword arguments.

    Args:
        model_checkpoint_callback_kwargs: Optional per-model keyword arguments.

    Returns:
        checkpoint_callback_kwargs: Merged shared and per-model keyword arguments.
    """
    checkpoint_callback_kwargs = copy.deepcopy(self.__shared_checkpoint_callback_kwargs)
    if model_checkpoint_callback_kwargs is not None:
        checkpoint_callback_kwargs.update(model_checkpoint_callback_kwargs)
    return checkpoint_callback_kwargs

__next_experiment_name(save_dir)

Return the next available experiment directory name.

Parameters:

Name Type Description Default
save_dir Path

Base directory containing experiment directories.

required

Returns:

Name Type Description
experiment_name Path

Next experiment directory name.

Source code in hypertorch/train/trainer.py
def __next_experiment_name(self, save_dir: Path) -> Path:
    """
    Return the next available experiment directory name.

    Args:
        save_dir: Base directory containing experiment directories.

    Returns:
        experiment_name: Next experiment directory name.
    """
    existing_experiment_names: list[str] = [
        dir.name
        for dir in save_dir.iterdir()
        if dir.is_dir() and dir.name.startswith(self.EXPERIMENT_NAME_PREFIX)
    ]
    if len(existing_experiment_names) < 1:
        return Path(
            f"{self.EXPERIMENT_NAME_PREFIX}{self.EXPERIMENT_SEPARATOR}{self.FIRST_EXPERIMENT_NUMBER}"
        )

    last_experiment_number = max(
        int(experiment_name.split(self.EXPERIMENT_SEPARATOR)[1])
        for experiment_name in existing_experiment_names
        if experiment_name.split(self.EXPERIMENT_SEPARATOR)[1].isdigit()
    )
    return Path(
        f"{self.EXPERIMENT_NAME_PREFIX}{self.EXPERIMENT_SEPARATOR}{last_experiment_number + 1}"
    )

__logdir(default_root_dir, experiment_name)

Resolve the log directory for this trainer instance.

Auto-named directories are exported through an environment variable indexed by trainer construction order. This allows Lightning-relaunched child processes to reuse the parent's directory, provided they reconstruct auto-named trainers in the same order. It does not coordinate sibling processes started by external launchers.

Parameters:

Name Type Description Default
default_root_dir str | Path | None

Optional root directory for logs.

required
experiment_name str | None

Optional explicit experiment directory name.

required

Returns:

Name Type Description
log_dir Path

Resolved log directory.

Source code in hypertorch/train/trainer.py
def __logdir(
    self,
    default_root_dir: str | Path | None,
    experiment_name: str | None,
) -> Path:
    """
    Resolve the log directory for this trainer instance.

    Auto-named directories are exported through an environment variable indexed by
    trainer construction order. This allows Lightning-relaunched child processes to
    reuse the parent's directory, provided they reconstruct auto-named trainers in the
    same order. It does not coordinate sibling processes started by external launchers.

    Args:
        default_root_dir: Optional root directory for logs.
        experiment_name: Optional explicit experiment directory name.

    Returns:
        log_dir: Resolved log directory.
    """
    base_dir = (
        Path(self.DEFAULT_BASE_LOG_DIR) if default_root_dir is None else Path(default_root_dir)
    )
    if experiment_name is None:
        experiment_index = next(AUTO_EXPERIMENT_INDEX)
        environment_variable = f"{AUTO_EXPERIMENT_DIR_ENV_PREFIX}_{experiment_index}"
        inherited_log_dir = os.environ.get(environment_variable)
        if inherited_log_dir is not None:
            # If a parent process has already reserved an auto-named directory, reuse it.
            return Path(inherited_log_dir)

        # Create the next experiment directory and reserve it for this trainer instance,
        # but only if it is not already reserved by a parent process. This ensures that
        # Lightning-relaunched child processes can reuse the same directory when they
        # reconstruct auto-named trainers in the same order.
        log_dir = self.__reserve_next_experiment_dir(base_dir).resolve()

        # Export the reserved directory through an environment variable for child processes
        # to inherit, in case this is a distributed training setup.
        os.environ[environment_variable] = str(log_dir)
        return log_dir

    return base_dir / experiment_name

__reserve_next_experiment_dir(base_dir)

Atomically select and reserve the next experiment directory.

Parameters:

Name Type Description Default
base_dir Path

Base directory containing experiment directories.

required

Returns:

Name Type Description
experiment_dir Path

Reserved experiment directory.

Source code in hypertorch/train/trainer.py
def __reserve_next_experiment_dir(self, base_dir: Path) -> Path:
    """
    Atomically select and reserve the next experiment directory.

    Args:
        base_dir: Base directory containing experiment directories.

    Returns:
        experiment_dir: Reserved experiment directory.
    """
    base_dir.mkdir(parents=True, exist_ok=True)

    while True:  # Until we successfully reserve a directory, keep trying
        new_experiment_name = self.__next_experiment_name(base_dir)
        new_experiment_dir = base_dir / new_experiment_name

        try:
            new_experiment_dir.mkdir(exist_ok=False)
        except FileExistsError:
            # Another trainer reserved this name after we calculated it,
            # so we need to try again to find the next available name.
            continue

        return new_experiment_dir

__setup_logger(model_config, logger)

Resolve loggers for a model configuration.

Parameters:

Name Type Description Default
model_config ModelConfig

Model configuration being prepared.

required
logger Logger | Iterable[Logger] | bool | None

User-provided logger configuration.

required

Returns:

Name Type Description
logger Logger | Iterable[Logger] | bool | None

User-provided logger configuration or default HyperTorch loggers.

Source code in hypertorch/train/trainer.py
def __setup_logger(
    self,
    model_config: ModelConfig,
    logger: Logger | Iterable[Logger] | bool | None,
) -> Logger | Iterable[Logger] | bool | None:
    """
    Resolve loggers for a model configuration.

    Args:
        model_config: Model configuration being prepared.
        logger: User-provided logger configuration.

    Returns:
        logger: User-provided logger configuration or default HyperTorch loggers.
    """
    if logger is not None:
        return logger

    loggers: list[Logger] = [
        CSVLogger(
            save_dir=self.log_dir,
            name=model_config.name,
            version=f"{self.VERSION_NAME_PREFIX}_{model_config.version}",
        ),
        MarkdownTableLogger(
            save_dir=self.log_dir,
            model_name=model_config.full_model_name(),
            experiment_name=self.__experiment_key,
        ),
        LaTexTableLogger(
            save_dir=self.log_dir,
            model_name=model_config.full_model_name(),
            experiment_name=self.__experiment_key,
            options={
                "table_caption": "Results for Experiments",
                "sort_by": ["des", "asc"],
                "border": False,
            },
        ),
    ]

    if self.__is_tensorboard_available():
        from lightning.pytorch.loggers import TensorBoardLogger

        loggers.append(
            TensorBoardLogger(
                save_dir=self.log_dir,
                name=model_config.name,
                version=f"{self.VERSION_NAME_PREFIX}_{model_config.version}",
            ),
        )

    return loggers

__setup_callbacks(model_config, model_index, has_duplicate_full_model_name, callbacks, enable_checkpointing, checkpoint_callback_kwargs)

Resolve callbacks for a model configuration.

Parameters:

Name Type Description Default
model_config ModelConfig

Model configuration being prepared.

required
model_index int

Index of the model in model_configs.

required
has_duplicate_full_model_name bool

Whether the full model name is duplicated.

required
callbacks list[Callback] | Callback | None

User-provided callbacks.

required
enable_checkpointing bool

Whether checkpointing is enabled.

required
checkpoint_callback_kwargs dict[str, Any]

Resolved default checkpoint callback keyword arguments.

required

Returns:

Name Type Description
callbacks list[Callback] | Callback | None

Resolved callbacks for the trainer.

Source code in hypertorch/train/trainer.py
def __setup_callbacks(
    self,
    model_config: ModelConfig,
    model_index: int,
    has_duplicate_full_model_name: bool,
    callbacks: list[Callback] | Callback | None,
    enable_checkpointing: bool,
    checkpoint_callback_kwargs: dict[str, Any],
) -> list[Callback] | Callback | None:
    """
    Resolve callbacks for a model configuration.

    Args:
        model_config: Model configuration being prepared.
        model_index: Index of the model in ``model_configs``.
        has_duplicate_full_model_name: Whether the full model name is duplicated.
        callbacks: User-provided callbacks.
        enable_checkpointing: Whether checkpointing is enabled.
        checkpoint_callback_kwargs: Resolved default checkpoint callback keyword arguments.

    Returns:
        callbacks: Resolved callbacks for the trainer.
    """
    model_callbacks = copy.deepcopy(callbacks)
    if not enable_checkpointing:
        return model_callbacks

    checkpoint_dir = self.__checkpoint_dir(
        model_config=model_config,
        model_index=model_index,
        has_duplicate_full_model_name=has_duplicate_full_model_name,
        checkpoint_callback_kwargs=checkpoint_callback_kwargs,
    )
    callback_list = self.__to_callback_list(model_callbacks)
    checkpoint_callbacks = [
        callback for callback in callback_list if isinstance(callback, ModelCheckpoint)
    ]

    if len(checkpoint_callbacks) < 1:
        checkpoint_callback_kwargs = copy.deepcopy(checkpoint_callback_kwargs)
        checkpoint_callback_kwargs["dirpath"] = checkpoint_dir
        callback_list.append(ModelCheckpoint(**checkpoint_callback_kwargs))
        return callback_list

    for callback in checkpoint_callbacks:
        if callback.dirpath is None:
            callback.dirpath = str(checkpoint_dir)
    return callback_list

__setup_trainer(options)

Create a Lightning Trainer with resolved shared and per-model settings.

Parameters:

Name Type Description Default
options Mapping[str, Any]

Merged keyword arguments for the Lightning Trainer.

required

Returns:

Name Type Description
trainer Trainer

Configured Lightning Trainer.

Source code in hypertorch/train/trainer.py
def __setup_trainer(self, options: Mapping[str, Any]) -> L.Trainer:
    """
    Create a Lightning Trainer with resolved shared and per-model settings.

    Args:
        options: Merged keyword arguments for the Lightning Trainer.

    Returns:
        trainer: Configured Lightning Trainer.
    """
    trainer_options = dict(options)
    trainer_options["callbacks"] = copy.deepcopy(trainer_options["callbacks"])
    return L.Trainer(**trainer_options)

__should_skip_test_on_current_rank(model_config, test_trainer)

Check whether the current rank should skip testing.

Parameters:

Name Type Description Default
model_config ModelConfig

Model configuration being tested.

required
test_trainer Trainer

Trainer selected for testing.

required

Returns:

Name Type Description
result bool

True when this process should skip the test call.

Source code in hypertorch/train/trainer.py
def __should_skip_test_on_current_rank(
    self,
    model_config: ModelConfig,
    test_trainer: L.Trainer,
) -> bool:
    """
    Check whether the current rank should skip testing.

    Args:
        model_config: Model configuration being tested.
        test_trainer: Trainer selected for testing.

    Returns:
        result: ``True`` when this process should skip the test call.
    """
    if not self.__is_separate_single_process_test_trainer(model_config, test_trainer):
        return False

    if model_config.trainer is not None:
        return not model_config.trainer.is_global_zero

    return self.distributed_global_rank != 0

__to_callback_list(callbacks)

Normalize callback configuration to a list.

Parameters:

Name Type Description Default
callbacks list[Callback] | Callback | None

Optional callback, callback list, or None.

required

Returns:

Name Type Description
callbacks list[Callback]

Callback list.

Source code in hypertorch/train/trainer.py
def __to_callback_list(self, callbacks: list[Callback] | Callback | None) -> list[Callback]:
    """
    Normalize callback configuration to a list.

    Args:
        callbacks: Optional callback, callback list, or ``None``.

    Returns:
        callbacks: Callback list.
    """
    if callbacks is None:
        return []
    if isinstance(callbacks, Callback):
        return [callbacks]
    return callbacks

__wait_for_distributed()

Synchronize and tear down an initialized distributed process group.

Source code in hypertorch/train/trainer.py
def __wait_for_distributed(self) -> None:
    """
    Synchronize and tear down an initialized distributed process group.
    """
    if torch.distributed.is_available() and torch.distributed.is_initialized():
        torch.distributed.barrier()
        torch.distributed.destroy_process_group()

colorize_metric_value(metric, value, text, metric_bounds, sort_order)

Wrap a formatted metric value in a LaTex cell color command.

Parameters:

Name Type Description Default
metric str

Metric name.

required
value float

Numeric metric value.

required
text str

Already formatted metric text.

required
metric_bounds Mapping[str, tuple[float, float]] | None

Optional metric bounds used for color scaling.

required
sort_order str

"asc" when lower is better, or "des" when higher is better.

required

Returns:

Name Type Description
text str

Original or colorized LaTex cell text.

Raises:

Type Description
ValueError

If sort_order is unsupported.

Source code in hypertorch/train/latex_logger.py
def colorize_metric_value(
    metric: str,
    value: float,
    text: str,
    metric_bounds: Mapping[str, tuple[float, float]] | None,
    sort_order: str,
) -> str:
    """
    Wrap a formatted metric value in a LaTex cell color command.

    Args:
        metric: Metric name.
        value: Numeric metric value.
        text: Already formatted metric text.
        metric_bounds: Optional metric bounds used for color scaling.
        sort_order: ``"asc"`` when lower is better, or ``"des"`` when higher is better.

    Returns:
        text: Original or colorized LaTex cell text.

    Raises:
        ValueError: If ``sort_order`` is unsupported.
    """
    bounds = None if metric_bounds is None else metric_bounds.get(metric)
    if bounds is None:
        return text

    normalized_sort_order = sort_order.lower()
    if normalized_sort_order not in ("asc", "des"):
        raise ValueError(f"'sort_order' must be 'asc' or 'des', got {sort_order!r}.")

    min_metric_value, max_metric_value = bounds
    if max_metric_value == min_metric_value:
        quality = 1.0
    else:
        normalized_metric_value = (value - min_metric_value) / (
            max_metric_value - min_metric_value
        )  # 0..1, low->high
        quality = (
            (1.0 - normalized_metric_value)
            if normalized_sort_order == "asc"
            else normalized_metric_value
        )

    red = round((1.0 - quality) * 100)
    green = round(quality * 100)

    # Blend toward white to keep the gradient readable but less bright.
    soften = 0.35
    red_chan = round(((red / 100) * (1.0 - soften) + soften) * 255)
    green_chan = round(((green / 100) * (1.0 - soften) + soften) * 255)
    blue_chan = round(soften * 255)

    return rf"\cellcolor[HTML]{{{red_chan:02X}{green_chan:02X}{blue_chan:02X}}}{text}"