topasmoo 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- TopasMOO/__init__.py +81 -0
- TopasMOO/exceptions.py +23 -0
- TopasMOO/io.py +118 -0
- TopasMOO/metrics.py +141 -0
- TopasMOO/optimizers.py +1470 -0
- TopasMOO/plotting/__init__.py +57 -0
- TopasMOO/plotting/comprehensive.py +270 -0
- TopasMOO/plotting/convergence.py +221 -0
- TopasMOO/plotting/correlation.py +87 -0
- TopasMOO/plotting/decision.py +147 -0
- TopasMOO/plotting/hypervolume.py +87 -0
- TopasMOO/plotting/parallel.py +157 -0
- TopasMOO/plotting/pareto.py +413 -0
- TopasMOO/plotting/petal.py +254 -0
- TopasMOO/plotting/population.py +102 -0
- TopasMOO/plotting/style.py +337 -0
- TopasMOO/plotting/topasmoo_fast.mplstyle +47 -0
- TopasMOO/plotting/topasmoo_pub_clean.mplstyle +60 -0
- TopasMOO/plotting/topasmoo_pub_ieee.mplstyle +67 -0
- TopasMOO/plotting/topasmoo_pub_medicalphysics.mplstyle +72 -0
- TopasMOO/plotting/topasmoo_pub_nature.mplstyle +54 -0
- TopasMOO/utilities.py +86 -0
- topasmoo-0.2.0.dist-info/METADATA +147 -0
- topasmoo-0.2.0.dist-info/RECORD +26 -0
- topasmoo-0.2.0.dist-info/WHEEL +4 -0
- topasmoo-0.2.0.dist-info/licenses/LICENSE +22 -0
TopasMOO/__init__.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TopasMOO: Multi-Objective Optimization for TOPAS Monte Carlo Simulations
|
|
3
|
+
|
|
4
|
+
TopasMOO extends the TopasOpt framework to handle multiple competing objectives
|
|
5
|
+
using pymoo algorithms, particularly NSGA-II.
|
|
6
|
+
|
|
7
|
+
Each optimization must import:
|
|
8
|
+
``NSGAII_Optimizer``
|
|
9
|
+
The optimizer you construct and run (``.RunOptimization()``). This is the
|
|
10
|
+
main entry point for most users.
|
|
11
|
+
``TopasMOOBaseClass``
|
|
12
|
+
Base class to subclass if you want to drive a different pymoo algorithm.
|
|
13
|
+
|
|
14
|
+
The full visualization toolbox (Pareto fronts, petal diagrams, convergence plots,
|
|
15
|
+
etc.) lives in the TopasMOO.plotting subpackage; only the style entry
|
|
16
|
+
points are re-exported here for convenience. Pareto-analysis metrics live in
|
|
17
|
+
TopasMOO.metrics and log helpers in TopasMOO.io.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import importlib.metadata
|
|
21
|
+
|
|
22
|
+
__author__ = "Mindy Harkness"
|
|
23
|
+
try:
|
|
24
|
+
__version__ = importlib.metadata.version("topasmoo")
|
|
25
|
+
except importlib.metadata.PackageNotFoundError:
|
|
26
|
+
__version__ = "0.2.0-dev"
|
|
27
|
+
|
|
28
|
+
# Exceptions
|
|
29
|
+
from .exceptions import (
|
|
30
|
+
InvalidParameterError,
|
|
31
|
+
MalformedOutputError,
|
|
32
|
+
ObjectiveFunctionError,
|
|
33
|
+
TopasExecutionError,
|
|
34
|
+
TopasMOOError,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# I/O utilities
|
|
38
|
+
from .io import LogParetoFrontToFile, ReadInMultiObjectiveLogFile
|
|
39
|
+
|
|
40
|
+
# Metrics
|
|
41
|
+
from .metrics import (
|
|
42
|
+
calculate_crowding_distance,
|
|
43
|
+
calculate_dominance_rank,
|
|
44
|
+
calculate_knee_point,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Core optimizer classes (TopasProblem is an internal pymoo adapter that stays
|
|
48
|
+
# in TopasMOO.optimizers).
|
|
49
|
+
from .optimizers import NSGAII_Optimizer, TopasMOOBaseClass
|
|
50
|
+
|
|
51
|
+
# Plotting style entry points (the full plotting API lives in TopasMOO.plotting).
|
|
52
|
+
from .plotting import (
|
|
53
|
+
apply_style,
|
|
54
|
+
available_publication_variants,
|
|
55
|
+
publication_style,
|
|
56
|
+
save_publication_figure,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
# Core classes
|
|
61
|
+
"NSGAII_Optimizer",
|
|
62
|
+
"TopasMOOBaseClass",
|
|
63
|
+
# Exceptions
|
|
64
|
+
"TopasMOOError",
|
|
65
|
+
"TopasExecutionError",
|
|
66
|
+
"InvalidParameterError",
|
|
67
|
+
"ObjectiveFunctionError",
|
|
68
|
+
"MalformedOutputError",
|
|
69
|
+
# Metrics
|
|
70
|
+
"calculate_knee_point",
|
|
71
|
+
"calculate_crowding_distance",
|
|
72
|
+
"calculate_dominance_rank",
|
|
73
|
+
# IO
|
|
74
|
+
"ReadInMultiObjectiveLogFile",
|
|
75
|
+
"LogParetoFrontToFile",
|
|
76
|
+
# Plotting style entry points (full plotting API under TopasMOO.plotting)
|
|
77
|
+
"apply_style",
|
|
78
|
+
"available_publication_variants",
|
|
79
|
+
"publication_style",
|
|
80
|
+
"save_publication_figure",
|
|
81
|
+
]
|
TopasMOO/exceptions.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom exception classes for TopasMOO.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TopasMOOError(Exception):
|
|
7
|
+
"""Base exception for all TopasMOO errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TopasExecutionError(TopasMOOError):
|
|
11
|
+
"""Raised when a TOPAS simulation fails to execute."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class InvalidParameterError(TopasMOOError):
|
|
15
|
+
"""Raised when optimization parameters are invalid or inconsistent."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ObjectiveFunctionError(TopasMOOError):
|
|
19
|
+
"""Raised when the objective function returns unexpected results."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MalformedOutputError(TopasMOOError):
|
|
23
|
+
"""Raised when TOPAS output files are missing or malformed."""
|
TopasMOO/io.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
I/O utilities for reading and writing TopasMOO optimization logs.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
from collections.abc import Iterable, Sequence
|
|
10
|
+
from typing import TYPE_CHECKING, Union
|
|
11
|
+
|
|
12
|
+
from .exceptions import MalformedOutputError
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
PathLike = Union[str, os.PathLike]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def ReadInMultiObjectiveLogFile(LogFilePath: PathLike) -> dict[str, list[float]]:
|
|
23
|
+
"""Read a multi-objective optimization log file into a dictionary.
|
|
24
|
+
|
|
25
|
+
Parses the comma-separated log format produced by TopasMOO during
|
|
26
|
+
optimization, extracting iteration numbers, parameter values, and
|
|
27
|
+
objective function values.
|
|
28
|
+
|
|
29
|
+
:param LogFilePath: Path to the optimization log file
|
|
30
|
+
(typically ``logs/OptimizationLogs.txt``).
|
|
31
|
+
|
|
32
|
+
:returns: Dictionary mapping column names to lists of float values.
|
|
33
|
+
Keys include ``'Iteration'``, parameter names, and
|
|
34
|
+
``'ObjectiveFunction_1'``, ``'ObjectiveFunction_2'``, etc.
|
|
35
|
+
|
|
36
|
+
:raises FileNotFoundError: If the log file does not exist.
|
|
37
|
+
:raises MalformedOutputError: If a data row contains a non-numeric value
|
|
38
|
+
after the ``key: value`` split.
|
|
39
|
+
"""
|
|
40
|
+
log_path = os.fspath(LogFilePath)
|
|
41
|
+
if not os.path.isfile(log_path):
|
|
42
|
+
raise FileNotFoundError(f"Could not find log file at {log_path}")
|
|
43
|
+
|
|
44
|
+
results: dict[str, list[float]] = {}
|
|
45
|
+
with open(log_path, "r") as f:
|
|
46
|
+
lines = f.readlines()
|
|
47
|
+
|
|
48
|
+
if not lines:
|
|
49
|
+
return results
|
|
50
|
+
|
|
51
|
+
if not any("Iteration" in line for line in lines):
|
|
52
|
+
logger.warning("Log file format not recognized or empty: %s", log_path)
|
|
53
|
+
return results
|
|
54
|
+
|
|
55
|
+
for line_number, line in enumerate(lines, start=1):
|
|
56
|
+
if not line.startswith("Iteration"):
|
|
57
|
+
continue
|
|
58
|
+
for entry in line.split(","):
|
|
59
|
+
parts = entry.split(":")
|
|
60
|
+
if len(parts) < 2:
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
key = parts[0].strip()
|
|
64
|
+
raw_value = parts[1].strip()
|
|
65
|
+
try:
|
|
66
|
+
value = float(raw_value)
|
|
67
|
+
except ValueError as exc:
|
|
68
|
+
raise MalformedOutputError(
|
|
69
|
+
f"Could not parse value '{raw_value}' for key '{key}' "
|
|
70
|
+
f"on line {line_number} of {log_path}"
|
|
71
|
+
) from exc
|
|
72
|
+
|
|
73
|
+
results.setdefault(key, []).append(value)
|
|
74
|
+
|
|
75
|
+
return results
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def LogParetoFrontToFile(
|
|
79
|
+
LogFilePath: PathLike,
|
|
80
|
+
ParetoObjectives: np.ndarray | Iterable[Iterable[float]],
|
|
81
|
+
ParameterNames: Sequence[str],
|
|
82
|
+
n_objectives: int,
|
|
83
|
+
ParetoDecisionVars: np.ndarray | Iterable[Iterable[float]] | None = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Write the current Pareto front to a CSV-style log file.
|
|
86
|
+
|
|
87
|
+
:param LogFilePath: Output file path.
|
|
88
|
+
:param ParetoObjectives: Array of shape ``(n_solutions, n_objectives)``
|
|
89
|
+
containing objective values for all non-dominated solutions.
|
|
90
|
+
:param ParameterNames: Decision-variable names, used as the column headers for
|
|
91
|
+
the decision-variable values when ``ParetoDecisionVars`` is given.
|
|
92
|
+
:param n_objectives: Number of objective functions.
|
|
93
|
+
:param ParetoDecisionVars: Optional array of shape
|
|
94
|
+
``(n_solutions, len(ParameterNames))`` aligned row-for-row with
|
|
95
|
+
``ParetoObjectives``. When provided, each parameter is written as an
|
|
96
|
+
additional column so the file fully describes each solution. When
|
|
97
|
+
``None``, only objective columns are written.
|
|
98
|
+
"""
|
|
99
|
+
with open(os.fspath(LogFilePath), "w") as f:
|
|
100
|
+
header = "Solution_Index"
|
|
101
|
+
for i in range(n_objectives):
|
|
102
|
+
header += f",Objective_{i+1}"
|
|
103
|
+
if ParetoDecisionVars is not None:
|
|
104
|
+
for name in ParameterNames:
|
|
105
|
+
header += f",{name}"
|
|
106
|
+
header += "\n"
|
|
107
|
+
f.write(header)
|
|
108
|
+
|
|
109
|
+
decision_rows = list(ParetoDecisionVars) if ParetoDecisionVars is not None else None
|
|
110
|
+
for i, objectives in enumerate(ParetoObjectives):
|
|
111
|
+
line = f"{i}"
|
|
112
|
+
for obj_val in objectives:
|
|
113
|
+
line += f",{obj_val:.6f}"
|
|
114
|
+
if decision_rows is not None:
|
|
115
|
+
for var_val in decision_rows[i]:
|
|
116
|
+
line += f",{var_val:.6f}"
|
|
117
|
+
line += "\n"
|
|
118
|
+
f.write(line)
|
TopasMOO/metrics.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Multi-objective optimization metrics for Pareto front analysis.
|
|
3
|
+
|
|
4
|
+
All metrics assume minimization of objectives.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def normalize_objectives(
|
|
13
|
+
objectives: np.ndarray,
|
|
14
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
15
|
+
"""Min-max normalize objectives per column to ``[0, 1]``.
|
|
16
|
+
|
|
17
|
+
Each objective (column) is scaled by its observed range across the given
|
|
18
|
+
solutions. Columns with zero range (a single distinct value) are left at
|
|
19
|
+
``0`` rather than dividing by zero.
|
|
20
|
+
|
|
21
|
+
:param objectives: Array of shape ``(n_solutions, n_objectives)``.
|
|
22
|
+
|
|
23
|
+
:returns: Tuple ``(normalized, ideal, nadir)`` where ``ideal`` and ``nadir`` are
|
|
24
|
+
the per-objective minimum and maximum, and ``normalized`` has the same
|
|
25
|
+
shape as ``objectives``.
|
|
26
|
+
"""
|
|
27
|
+
objectives = np.asarray(objectives, dtype=float)
|
|
28
|
+
ideal = objectives.min(axis=0)
|
|
29
|
+
nadir = objectives.max(axis=0)
|
|
30
|
+
obj_range = nadir - ideal
|
|
31
|
+
obj_range[obj_range == 0] = 1
|
|
32
|
+
normalized = (objectives - ideal) / obj_range
|
|
33
|
+
return normalized, ideal, nadir
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def calculate_knee_point(pareto_objectives: np.ndarray) -> int:
|
|
37
|
+
"""Find the knee point (best trade-off) on a Pareto front.
|
|
38
|
+
|
|
39
|
+
Uses a trade-off method: the knee is the solution with the minimum
|
|
40
|
+
sum of min-max normalized objectives, representing the best balanced
|
|
41
|
+
compromise across all objectives.
|
|
42
|
+
|
|
43
|
+
:param pareto_objectives: Array of shape ``(n_solutions, n_objectives)``.
|
|
44
|
+
All objectives are assumed to be minimized.
|
|
45
|
+
|
|
46
|
+
:returns: Index (int) of the knee point solution in the input array.
|
|
47
|
+
"""
|
|
48
|
+
normalized, _ideal, _nadir = normalize_objectives(pareto_objectives)
|
|
49
|
+
|
|
50
|
+
total_objectives = normalized.sum(axis=1)
|
|
51
|
+
return int(np.argmin(total_objectives))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def calculate_crowding_distance(pareto_objectives: np.ndarray) -> np.ndarray:
|
|
55
|
+
"""Calculate NSGA-II crowding distance for each solution.
|
|
56
|
+
|
|
57
|
+
Crowding distance measures how isolated a solution is in objective
|
|
58
|
+
space. Higher values indicate solutions in less crowded regions,
|
|
59
|
+
which are preferred during selection to maintain diversity.
|
|
60
|
+
|
|
61
|
+
Boundary solutions (best/worst in any single objective) receive
|
|
62
|
+
infinite crowding distance.
|
|
63
|
+
|
|
64
|
+
:param pareto_objectives: Array of shape ``(n_solutions, n_objectives)``.
|
|
65
|
+
|
|
66
|
+
:returns: Array of crowding distances with shape ``(n_solutions,)``.
|
|
67
|
+
"""
|
|
68
|
+
n_solutions, n_objectives = pareto_objectives.shape
|
|
69
|
+
|
|
70
|
+
if n_solutions <= 2:
|
|
71
|
+
return np.full(n_solutions, np.inf)
|
|
72
|
+
|
|
73
|
+
crowding = np.zeros(n_solutions)
|
|
74
|
+
|
|
75
|
+
for m in range(n_objectives):
|
|
76
|
+
sorted_indices = np.argsort(pareto_objectives[:, m])
|
|
77
|
+
obj_values = pareto_objectives[sorted_indices, m]
|
|
78
|
+
|
|
79
|
+
crowding[sorted_indices[0]] = np.inf
|
|
80
|
+
crowding[sorted_indices[-1]] = np.inf
|
|
81
|
+
|
|
82
|
+
obj_range = obj_values[-1] - obj_values[0]
|
|
83
|
+
if obj_range == 0:
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
for i in range(1, n_solutions - 1):
|
|
87
|
+
crowding[sorted_indices[i]] += (
|
|
88
|
+
obj_values[i + 1] - obj_values[i - 1]
|
|
89
|
+
) / obj_range
|
|
90
|
+
|
|
91
|
+
return crowding
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def calculate_dominance_rank(objectives: np.ndarray) -> np.ndarray:
|
|
95
|
+
"""Assign dominance ranks using fast non-dominated sorting.
|
|
96
|
+
|
|
97
|
+
Rank 0 contains the Pareto front (non-dominated solutions).
|
|
98
|
+
Rank 1 contains solutions dominated only by rank-0 solutions,
|
|
99
|
+
and so on.
|
|
100
|
+
|
|
101
|
+
All objectives are assumed to be minimized.
|
|
102
|
+
|
|
103
|
+
:param objectives: Array of shape ``(n_solutions, n_objectives)``.
|
|
104
|
+
|
|
105
|
+
:returns: Integer array of dominance ranks with shape ``(n_solutions,)``.
|
|
106
|
+
"""
|
|
107
|
+
n_solutions = len(objectives)
|
|
108
|
+
domination_count = np.zeros(n_solutions, dtype=int)
|
|
109
|
+
dominated_solutions = [[] for _ in range(n_solutions)]
|
|
110
|
+
ranks = np.zeros(n_solutions, dtype=int)
|
|
111
|
+
|
|
112
|
+
for i in range(n_solutions):
|
|
113
|
+
for j in range(i + 1, n_solutions):
|
|
114
|
+
if np.all(objectives[i] <= objectives[j]) and np.any(
|
|
115
|
+
objectives[i] < objectives[j]
|
|
116
|
+
):
|
|
117
|
+
dominated_solutions[i].append(j)
|
|
118
|
+
domination_count[j] += 1
|
|
119
|
+
elif np.all(objectives[j] <= objectives[i]) and np.any(
|
|
120
|
+
objectives[j] < objectives[i]
|
|
121
|
+
):
|
|
122
|
+
dominated_solutions[j].append(i)
|
|
123
|
+
domination_count[i] += 1
|
|
124
|
+
|
|
125
|
+
current_front = np.where(domination_count == 0)[0]
|
|
126
|
+
rank = 0
|
|
127
|
+
|
|
128
|
+
while len(current_front) > 0:
|
|
129
|
+
ranks[current_front] = rank
|
|
130
|
+
next_front = []
|
|
131
|
+
|
|
132
|
+
for i in current_front:
|
|
133
|
+
for j in dominated_solutions[i]:
|
|
134
|
+
domination_count[j] -= 1
|
|
135
|
+
if domination_count[j] == 0:
|
|
136
|
+
next_front.append(j)
|
|
137
|
+
|
|
138
|
+
current_front = np.array(next_front)
|
|
139
|
+
rank += 1
|
|
140
|
+
|
|
141
|
+
return ranks
|