e2am 0.1.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.
Files changed (58) hide show
  1. e2am/__init__.py +45 -0
  2. e2am/cli/__init__.py +7 -0
  3. e2am/cli/loader.py +60 -0
  4. e2am/cli/main.py +334 -0
  5. e2am/config/__init__.py +21 -0
  6. e2am/config/settings.py +163 -0
  7. e2am/exceptions.py +45 -0
  8. e2am/integrations/__init__.py +22 -0
  9. e2am/integrations/huggingface.py +168 -0
  10. e2am/metrics/__init__.py +56 -0
  11. e2am/metrics/classification.py +124 -0
  12. e2am/metrics/green.py +153 -0
  13. e2am/metrics/tracker.py +99 -0
  14. e2am/monitoring/__init__.py +42 -0
  15. e2am/monitoring/api.py +167 -0
  16. e2am/monitoring/carbon.py +108 -0
  17. e2am/monitoring/energy.py +79 -0
  18. e2am/monitoring/result.py +137 -0
  19. e2am/monitoring/samplers.py +228 -0
  20. e2am/monitoring/session.py +263 -0
  21. e2am/optimize/__init__.py +8 -0
  22. e2am/optimize/analyzer.py +225 -0
  23. e2am/optimize/suggestions.py +35 -0
  24. e2am/plugins/__init__.py +23 -0
  25. e2am/plugins/base.py +48 -0
  26. e2am/plugins/mlflow.py +84 -0
  27. e2am/plugins/tensorboard.py +59 -0
  28. e2am/plugins/wandb.py +72 -0
  29. e2am/plugins/webhooks.py +98 -0
  30. e2am/profiler/__init__.py +21 -0
  31. e2am/profiler/flops.py +238 -0
  32. e2am/profiler/latency.py +138 -0
  33. e2am/profiler/memory.py +63 -0
  34. e2am/py.typed +0 -0
  35. e2am/reports/__init__.py +17 -0
  36. e2am/reports/common.py +158 -0
  37. e2am/reports/dashboard.py +121 -0
  38. e2am/reports/generate.py +82 -0
  39. e2am/reports/html.py +108 -0
  40. e2am/reports/leaderboard.py +65 -0
  41. e2am/reports/markdown.py +49 -0
  42. e2am/reports/pdf.py +112 -0
  43. e2am/trainer/__init__.py +33 -0
  44. e2am/trainer/callbacks.py +135 -0
  45. e2am/trainer/result.py +115 -0
  46. e2am/trainer/trainer.py +440 -0
  47. e2am/utils/__init__.py +22 -0
  48. e2am/utils/hardware.py +221 -0
  49. e2am/utils/logging.py +65 -0
  50. e2am/utils/nvml.py +178 -0
  51. e2am/visualization/__init__.py +15 -0
  52. e2am/visualization/plots.py +272 -0
  53. e2am/visualization/style.py +44 -0
  54. e2am-0.1.0.dist-info/METADATA +273 -0
  55. e2am-0.1.0.dist-info/RECORD +58 -0
  56. e2am-0.1.0.dist-info/WHEEL +4 -0
  57. e2am-0.1.0.dist-info/entry_points.txt +2 -0
  58. e2am-0.1.0.dist-info/licenses/LICENSE +21 -0
e2am/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """E2AM — Energy Efficient AI Models.
2
+
3
+ Automatic energy, carbon, and performance profiling for AI training with
4
+ almost zero code changes.
5
+
6
+ Example:
7
+ >>> from e2am import monitor
8
+ >>> with monitor(project="ResNet50"):
9
+ ... train()
10
+
11
+ >>> from e2am import Trainer
12
+ >>> trainer = Trainer(model=model, optimizer=optimizer,
13
+ ... train_loader=train_loader, val_loader=val_loader)
14
+ >>> trainer.fit()
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ __version__ = "0.1.0"
22
+
23
+ __all__ = ["Trainer", "__version__", "monitor"]
24
+
25
+ if TYPE_CHECKING: # pragma: no cover - import-time only for type checkers
26
+ from e2am.monitoring import monitor
27
+ from e2am.trainer import Trainer
28
+
29
+
30
+ def __getattr__(name: str) -> Any:
31
+ """Lazily resolve the public API.
32
+
33
+ Keeps ``import e2am`` fast and torch-free: the ``Trainer`` (which needs
34
+ PyTorch) is only imported when actually accessed, so monitoring-only
35
+ installs work on machines without PyTorch.
36
+ """
37
+ if name == "monitor":
38
+ from e2am.monitoring import monitor
39
+
40
+ return monitor
41
+ if name == "Trainer":
42
+ from e2am.trainer import Trainer
43
+
44
+ return Trainer
45
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
e2am/cli/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """The ``e2am`` command line interface (typer application)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from e2am.cli.main import app
6
+
7
+ __all__ = ["app"]
e2am/cli/loader.py ADDED
@@ -0,0 +1,60 @@
1
+ """Loading user model scripts for the CLI.
2
+
3
+ ``e2am train`` and ``e2am benchmark`` operate on a plain Python file that
4
+ exports well-known factory functions — no subclassing, no registration:
5
+
6
+ * ``get_model() -> nn.Module`` (required)
7
+ * ``get_loaders() -> (train_loader, val_loader | None)`` (train only)
8
+ * ``get_optimizer(model) -> torch.optim.Optimizer`` (optional; Adam 1e-3)
9
+ * ``get_loss() -> callable`` (optional; CrossEntropyLoss)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib.util
15
+ import sys
16
+ from pathlib import Path
17
+ from types import ModuleType
18
+ from typing import Any
19
+
20
+ from e2am.exceptions import ConfigError
21
+
22
+
23
+ def load_user_module(path: str | Path) -> ModuleType:
24
+ """Import a user's Python file as a module.
25
+
26
+ Raises:
27
+ ConfigError: If the file is missing or fails to import.
28
+ """
29
+ path = Path(path)
30
+ if not path.exists():
31
+ raise ConfigError(f"Script not found: {path}")
32
+ spec = importlib.util.spec_from_file_location(f"e2am_user_{path.stem}", path)
33
+ if spec is None or spec.loader is None:
34
+ raise ConfigError(f"Cannot import {path} as a Python module.")
35
+ module = importlib.util.module_from_spec(spec)
36
+ sys.modules[spec.name] = module
37
+ try:
38
+ spec.loader.exec_module(module)
39
+ except Exception as exc:
40
+ raise ConfigError(f"Error while importing {path}: {exc}") from exc
41
+ return module
42
+
43
+
44
+ def get_factory(module: ModuleType, name: str, required: bool = True) -> Any:
45
+ """Fetch a factory function from the user module.
46
+
47
+ Raises:
48
+ ConfigError: If a required factory is missing or not callable.
49
+ """
50
+ factory = getattr(module, name, None)
51
+ if factory is None:
52
+ if required:
53
+ raise ConfigError(
54
+ f"{module.__name__} must define `{name}()` "
55
+ f"(see the docs for the e2am script convention)."
56
+ )
57
+ return None
58
+ if not callable(factory):
59
+ raise ConfigError(f"`{name}` in the script is not callable.")
60
+ return factory
e2am/cli/main.py ADDED
@@ -0,0 +1,334 @@
1
+ """The ``e2am`` command line interface.
2
+
3
+ Commands mirror the Python API and share its code paths — the CLI is a thin
4
+ orchestration layer, never a second implementation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ import typer
14
+ from rich.console import Console
15
+ from rich.table import Table
16
+
17
+ import e2am
18
+ from e2am.config.settings import ExperimentConfig
19
+ from e2am.exceptions import E2AMError
20
+
21
+ if TYPE_CHECKING: # pragma: no cover
22
+ from e2am.monitoring.result import MonitorResult
23
+ from e2am.trainer.result import TrainingResult
24
+
25
+ app = typer.Typer(
26
+ name="e2am",
27
+ help="E2AM — Energy Efficient AI Models: automatic energy, carbon, and "
28
+ "performance profiling for AI training.",
29
+ no_args_is_help=True,
30
+ pretty_exceptions_show_locals=False,
31
+ )
32
+ console = Console()
33
+
34
+
35
+ def _fail(message: str) -> None:
36
+ console.print(f"[red]error:[/red] {message}")
37
+ raise typer.Exit(code=1)
38
+
39
+
40
+ @app.command()
41
+ def version() -> None:
42
+ """Print the E2AM version."""
43
+ console.print(f"e2am {e2am.__version__}")
44
+
45
+
46
+ @app.command()
47
+ def hardware(
48
+ as_json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
49
+ ) -> None:
50
+ """Detect hardware and its energy-measurement capabilities."""
51
+ from e2am.utils.hardware import detect_hardware
52
+
53
+ info = detect_hardware()
54
+ if as_json:
55
+ console.print_json(info.model_dump_json())
56
+ return
57
+ table = Table(title="Detected hardware", title_style="bold green")
58
+ table.add_column("Component", style="bold")
59
+ table.add_column("Details")
60
+ table.add_row("Host", f"{info.hostname} · {info.os} {info.os_version}")
61
+ table.add_row("Python", info.python_version)
62
+ table.add_row(
63
+ "CPU",
64
+ f"{info.cpu.model}\n"
65
+ f"{info.cpu.physical_cores or '?'} cores / {info.cpu.logical_cores or '?'} threads · "
66
+ f"TDP {info.cpu.tdp_w:.0f} W (estimation basis)",
67
+ )
68
+ table.add_row(
69
+ "RAM", f"{info.ram.total_gb:.1f} GB · ~{info.ram.estimated_power_w:.1f} W estimated"
70
+ )
71
+ for gpu in info.gpus:
72
+ sensor = (
73
+ "[green]live power sensor[/green]"
74
+ if gpu.supports_power_reading
75
+ else f"[yellow]no power sensor — estimating at {gpu.tdp_w:.0f} W × util[/yellow]"
76
+ )
77
+ table.add_row(
78
+ f"GPU {gpu.index}",
79
+ f"{gpu.name} · {gpu.total_memory_mb or 0:.0f} MB · "
80
+ f"driver {gpu.driver_version or '?'}\n{sensor}",
81
+ )
82
+ if info.torch_version:
83
+ cuda = f"CUDA {info.cuda_version}" if info.cuda_available else "CPU-only build"
84
+ table.add_row("PyTorch", f"{info.torch_version} · {cuda}")
85
+ else:
86
+ table.add_row("PyTorch", "[yellow]not installed[/yellow] (monitoring still works)")
87
+ console.print(table)
88
+
89
+
90
+ @app.command()
91
+ def train(
92
+ script: Path = typer.Argument(..., help="Python file defining get_model() and get_loaders()."),
93
+ config: Path | None = typer.Option(None, "--config", "-c", help="Experiment config YAML."),
94
+ epochs: int | None = typer.Option(None, help="Override epochs."),
95
+ device: str | None = typer.Option(None, help="Override device (cuda/cpu)."),
96
+ run_name: str | None = typer.Option(None, help="Override run name."),
97
+ ) -> None:
98
+ """Train a model from a script with full E2AM telemetry."""
99
+ from e2am.cli.loader import get_factory, load_user_module
100
+ from e2am.trainer import Trainer
101
+
102
+ try:
103
+ module = load_user_module(script)
104
+ get_model = get_factory(module, "get_model")
105
+ get_loaders = get_factory(module, "get_loaders")
106
+ get_optimizer = get_factory(module, "get_optimizer", required=False)
107
+ get_loss = get_factory(module, "get_loss", required=False)
108
+
109
+ experiment = ExperimentConfig.from_yaml(config) if config else ExperimentConfig()
110
+ model = get_model()
111
+ loaders = get_loaders()
112
+ train_loader, val_loader = loaders if isinstance(loaders, tuple) else (loaders, None)
113
+ if get_optimizer is not None:
114
+ optimizer = get_optimizer(model)
115
+ else:
116
+ import torch
117
+
118
+ optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
119
+ trainer = Trainer(
120
+ model=model,
121
+ optimizer=optimizer,
122
+ train_loader=train_loader,
123
+ val_loader=val_loader,
124
+ loss_fn=get_loss() if get_loss is not None else None,
125
+ config=experiment,
126
+ epochs=epochs,
127
+ device=device,
128
+ run_name=run_name,
129
+ )
130
+ result = trainer.fit()
131
+ except E2AMError as exc:
132
+ _fail(str(exc))
133
+ return
134
+ console.print(
135
+ f"[green]Run complete:[/green] {result.run_name} · status {result.status} · "
136
+ f"{result.total_energy_wh or 0:.4g} Wh · artifacts in "
137
+ f"{experiment.output.dir / result.run_name}"
138
+ )
139
+
140
+
141
+ @app.command()
142
+ def benchmark(
143
+ script: Path = typer.Argument(..., help="Python file defining get_model()."),
144
+ input_size: str = typer.Option(
145
+ ..., "--input-size", "-i", help="Input shape incl. batch, e.g. 8,3,224,224"
146
+ ),
147
+ device: str | None = typer.Option(None, help="Device (default: auto)."),
148
+ iterations: int = typer.Option(50, help="Timed iterations."),
149
+ warmup: int = typer.Option(10, help="Warmup iterations."),
150
+ ) -> None:
151
+ """Profile FLOPs, latency, throughput, and energy per inference."""
152
+ import torch
153
+
154
+ from e2am.cli.loader import get_factory, load_user_module
155
+ from e2am.monitoring.session import MonitorSession
156
+ from e2am.profiler import benchmark_latency, profile_model
157
+
158
+ try:
159
+ shape = tuple(int(part) for part in input_size.replace(" ", "").split(","))
160
+ except ValueError:
161
+ _fail(f"--input-size must be comma-separated integers, got {input_size!r}")
162
+ return
163
+ try:
164
+ module = load_user_module(script)
165
+ model = get_factory(module, "get_model")()
166
+ resolved = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
167
+ model = model.to(resolved)
168
+
169
+ profile = profile_model(model, input_size=shape, device=resolved)
170
+ session = MonitorSession(project="benchmark", run_name=script.stem)
171
+ session.config.sampling_interval_s = 0.2
172
+ session.start()
173
+ latency = benchmark_latency(
174
+ model, input_size=shape, device=resolved, warmup=warmup, iterations=iterations
175
+ )
176
+ monitor_result = session.stop()
177
+ except E2AMError as exc:
178
+ _fail(str(exc))
179
+ return
180
+
181
+ energy_j = monitor_result.total_energy_j
182
+ per_inference_j = energy_j / iterations if iterations else 0.0
183
+
184
+ table = Table(
185
+ title=f"Benchmark · {profile.model_name} @ {list(shape)} on {latency.device}",
186
+ title_style="bold green",
187
+ )
188
+ table.add_column("Metric", style="bold")
189
+ table.add_column("Value", justify="right")
190
+ table.add_row("Parameters", f"{profile.params:,}")
191
+ table.add_row("MACs / forward", f"{profile.macs:,} ({profile.gmacs:.3f} G)")
192
+ table.add_row("FLOPs / forward", f"{profile.flops:,} ({profile.gflops:.3f} G)")
193
+ table.add_row("Model size", f"{profile.model_size_mb:.2f} MB")
194
+ if profile.coverage < 1.0:
195
+ table.add_row("MAC coverage", f"[yellow]{profile.coverage * 100:.0f} %[/yellow]")
196
+ table.add_row("Latency mean", f"{latency.mean_ms:.3f} ms")
197
+ table.add_row("Latency p50 / p95", f"{latency.p50_ms:.3f} / {latency.p95_ms:.3f} ms")
198
+ table.add_row("Throughput", f"{latency.throughput_samples_per_s:.1f} samples/s")
199
+ table.add_row("Energy (whole benchmark)", f"{monitor_result.total_energy_wh:.5f} Wh")
200
+ table.add_row("Energy / inference", f"{per_inference_j:.4f} J")
201
+ console.print(table)
202
+
203
+
204
+ def _load_result(run_dir: Path) -> TrainingResult | MonitorResult:
205
+ from e2am.monitoring.result import MonitorResult
206
+ from e2am.trainer.result import TrainingResult
207
+
208
+ metrics = run_dir / "metrics.json"
209
+ if not metrics.exists():
210
+ _fail(f"No metrics.json in {run_dir}")
211
+ data = json.loads(metrics.read_text(encoding="utf-8"))
212
+ if "epochs_requested" in data:
213
+ return TrainingResult.model_validate(data)
214
+ return MonitorResult.model_validate(data)
215
+
216
+
217
+ @app.command()
218
+ def report(
219
+ run_dir: Path = typer.Argument(..., help="A run directory containing metrics.json."),
220
+ pdf: bool = typer.Option(False, "--pdf", help="Also generate report.pdf."),
221
+ ) -> None:
222
+ """(Re)generate plots and reports for a finished run."""
223
+ from e2am.config.settings import OutputConfig
224
+ from e2am.reports.generate import generate_run_artifacts
225
+
226
+ result = _load_result(run_dir)
227
+ written = generate_run_artifacts(result, run_dir, OutputConfig(save_pdf=pdf))
228
+ for path in written:
229
+ console.print(f" [green]wrote[/green] {path}")
230
+ console.print(f"[green]Regenerated {len(written)} artifact(s) for {result.run_name}.[/green]")
231
+
232
+
233
+ @app.command()
234
+ def optimize(
235
+ run_dir: Path = typer.Argument(..., help="A run directory containing metrics.json."),
236
+ ) -> None:
237
+ """Suggest efficiency optimizations based on a run's measurements."""
238
+ from e2am.optimize import analyze
239
+ from e2am.trainer.result import TrainingResult
240
+
241
+ result = _load_result(run_dir)
242
+ if not isinstance(result, TrainingResult):
243
+ _fail(
244
+ "optimize needs a training run (metrics.json from e2am.Trainer); "
245
+ "this directory holds a monitoring-only run."
246
+ )
247
+ return
248
+ suggestions = analyze(result)
249
+ if not suggestions:
250
+ console.print(
251
+ "[green]No optimization opportunities detected[/green] — this run "
252
+ "already uses the measured hardware efficiently."
253
+ )
254
+ return
255
+ table = Table(
256
+ title=f"Optimization suggestions · {result.project} / {result.run_name}",
257
+ title_style="bold green",
258
+ show_lines=True,
259
+ )
260
+ table.add_column("Priority", style="bold", width=8)
261
+ table.add_column("Suggestion")
262
+ table.add_column("Estimated savings", justify="right")
263
+ colors = {"high": "red", "medium": "yellow", "low": "cyan"}
264
+ for s in suggestions:
265
+ table.add_row(
266
+ f"[{colors.get(s.priority, 'white')}]{s.priority}[/]",
267
+ f"[bold]{s.title}[/bold]\n{s.rationale}\n[dim]{s.action}[/dim]",
268
+ s.savings_label(),
269
+ )
270
+ console.print(table)
271
+ quantified = sum(s.estimated_savings_wh or 0 for s in suggestions)
272
+ if quantified > 0:
273
+ console.print(
274
+ f"[bold]Quantified savings from this run's own data: " f"~{quantified:.4g} Wh[/bold]"
275
+ )
276
+
277
+
278
+ @app.command()
279
+ def compare(
280
+ run_dirs: list[Path] = typer.Argument(..., help="Two or more run directories."),
281
+ ) -> None:
282
+ """Compare runs side by side on their headline metrics."""
283
+ if len(run_dirs) < 2:
284
+ _fail("compare needs at least two run directories.")
285
+ flats = []
286
+ for run_dir in run_dirs:
287
+ result = _load_result(run_dir)
288
+ flats.append(result.to_flat_dict())
289
+
290
+ keys: list[str] = []
291
+ for flat in flats:
292
+ for key in flat:
293
+ if key not in keys:
294
+ keys.append(key)
295
+
296
+ table = Table(title="Run comparison", title_style="bold green")
297
+ table.add_column("Metric", style="bold")
298
+ for flat in flats:
299
+ table.add_column(str(flat.get("run_name", "?")), justify="right")
300
+ for key in keys:
301
+ if key == "run_name":
302
+ continue
303
+ values = []
304
+ for flat in flats:
305
+ value = flat.get(key)
306
+ values.append("—" if value in (None, "") else str(value))
307
+ table.add_row(key, *values)
308
+ console.print(table)
309
+
310
+
311
+ @app.command()
312
+ def dashboard(
313
+ results_dir: Path = typer.Argument(Path("results"), help="Results root directory."),
314
+ open_browser: bool = typer.Option(
315
+ True, "--open/--no-open", help="Open the dashboard in the default browser."
316
+ ),
317
+ ) -> None:
318
+ """Build a local HTML dashboard of all runs."""
319
+ from e2am.reports.dashboard import generate_dashboard
320
+
321
+ try:
322
+ path = generate_dashboard(results_dir)
323
+ except E2AMError as exc:
324
+ _fail(str(exc))
325
+ return
326
+ console.print(f"[green]Dashboard written:[/green] {path}")
327
+ if open_browser:
328
+ import webbrowser
329
+
330
+ webbrowser.open(path.resolve().as_uri())
331
+
332
+
333
+ if __name__ == "__main__":
334
+ app()
@@ -0,0 +1,21 @@
1
+ """Typed, YAML-loadable configuration for E2AM experiments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from e2am.config.settings import (
6
+ CarbonConfig,
7
+ ExperimentConfig,
8
+ MonitorConfig,
9
+ OutputConfig,
10
+ TrainerConfig,
11
+ load_config,
12
+ )
13
+
14
+ __all__ = [
15
+ "CarbonConfig",
16
+ "ExperimentConfig",
17
+ "MonitorConfig",
18
+ "OutputConfig",
19
+ "TrainerConfig",
20
+ "load_config",
21
+ ]
@@ -0,0 +1,163 @@
1
+ """Experiment configuration models.
2
+
3
+ Every knob in E2AM lives in a pydantic model here, giving three things at
4
+ once: validation with helpful errors, YAML round-tripping for reproducible
5
+ runs (``config.yaml`` is written into every results directory), and a single
6
+ documented source of truth for defaults.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+
14
+ import yaml
15
+ from pydantic import BaseModel, Field, field_validator
16
+
17
+ from e2am.exceptions import ConfigError
18
+
19
+ #: Global average grid carbon intensity in gCO2eq/kWh (IEA 2023 estimate).
20
+ WORLD_AVG_CARBON_INTENSITY = 475.0
21
+
22
+
23
+ class CarbonConfig(BaseModel):
24
+ """How carbon emissions are computed from energy."""
25
+
26
+ country_iso_code: str | None = Field(
27
+ default=None,
28
+ description="ISO 3166 country code (e.g. 'IND', 'USA') for region-aware intensity.",
29
+ )
30
+ carbon_intensity_g_per_kwh: float = Field(
31
+ default=WORLD_AVG_CARBON_INTENSITY,
32
+ gt=0,
33
+ description="Grid carbon intensity in gCO2eq/kWh. Overridden by "
34
+ "region lookup when a country code is set and lookup data is available.",
35
+ )
36
+
37
+
38
+ class MonitorConfig(BaseModel):
39
+ """Background monitoring behavior."""
40
+
41
+ sampling_interval_s: float = Field(
42
+ default=1.0,
43
+ ge=0.05,
44
+ le=60.0,
45
+ description="Seconds between hardware samples.",
46
+ )
47
+ gpu_indices: list[int] | None = Field(
48
+ default=None,
49
+ description="GPUs to monitor (None = all detected GPUs).",
50
+ )
51
+ cpu_tdp_w: float | None = Field(
52
+ default=None,
53
+ gt=0,
54
+ description="CPU TDP override in watts for energy estimation.",
55
+ )
56
+ carbon: CarbonConfig = Field(default_factory=CarbonConfig)
57
+
58
+
59
+ class OutputConfig(BaseModel):
60
+ """What gets written to disk after a run."""
61
+
62
+ dir: Path = Field(default=Path("results"), description="Root results directory.")
63
+ save_plots: bool = True
64
+ save_html: bool = True
65
+ save_pdf: bool = False
66
+ save_json: bool = True
67
+ save_csv: bool = True
68
+
69
+ @field_validator("dir", mode="before")
70
+ @classmethod
71
+ def _coerce_path(cls, value: str | Path) -> Path:
72
+ return Path(value)
73
+
74
+
75
+ class TrainerConfig(BaseModel):
76
+ """Training loop behavior for :class:`e2am.Trainer`."""
77
+
78
+ epochs: int = Field(default=10, ge=1)
79
+ device: str | None = Field(
80
+ default=None,
81
+ description="Target device ('cuda', 'cuda:1', 'cpu'). None = auto-detect.",
82
+ )
83
+ mixed_precision: bool = Field(
84
+ default=False,
85
+ description="Enable automatic mixed precision (torch.autocast + GradScaler).",
86
+ )
87
+ gradient_accumulation_steps: int = Field(default=1, ge=1)
88
+ max_grad_norm: float | None = Field(
89
+ default=None,
90
+ gt=0,
91
+ description="Clip gradients to this norm when set.",
92
+ )
93
+ log_every_n_steps: int = Field(default=50, ge=1)
94
+
95
+
96
+ class ExperimentConfig(BaseModel):
97
+ """Top-level configuration for one experiment run."""
98
+
99
+ project: str = Field(default="e2am", min_length=1)
100
+ run_name: str | None = Field(
101
+ default=None,
102
+ description="Run identifier. None = auto ('<project>-<timestamp>').",
103
+ )
104
+ seed: int | None = None
105
+ tags: list[str] = Field(default_factory=list)
106
+ notes: str = ""
107
+ monitor: MonitorConfig = Field(default_factory=MonitorConfig)
108
+ output: OutputConfig = Field(default_factory=OutputConfig)
109
+ trainer: TrainerConfig = Field(default_factory=TrainerConfig)
110
+
111
+ def resolved_run_name(self) -> str:
112
+ """Return the explicit run name or generate a timestamped one."""
113
+ if self.run_name:
114
+ return self.run_name
115
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
116
+ return f"{self.project}-{stamp}"
117
+
118
+ def run_dir(self) -> Path:
119
+ """Directory where this run's artifacts are written."""
120
+ return self.output.dir / self.resolved_run_name()
121
+
122
+ # ------------------------------------------------------------------
123
+ # YAML round-tripping
124
+ # ------------------------------------------------------------------
125
+
126
+ @classmethod
127
+ def from_yaml(cls, path: str | Path) -> ExperimentConfig:
128
+ """Load a configuration from a YAML file.
129
+
130
+ Args:
131
+ path: Path to a YAML file produced by :meth:`to_yaml` or written
132
+ by hand.
133
+
134
+ Raises:
135
+ ConfigError: If the file is missing, unparsable, or invalid.
136
+ """
137
+ path = Path(path)
138
+ if not path.exists():
139
+ raise ConfigError(f"Config file not found: {path}")
140
+ try:
141
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
142
+ except yaml.YAMLError as exc:
143
+ raise ConfigError(f"Invalid YAML in {path}: {exc}") from exc
144
+ try:
145
+ return cls.model_validate(data)
146
+ except Exception as exc:
147
+ raise ConfigError(f"Invalid configuration in {path}: {exc}") from exc
148
+
149
+ def to_yaml(self, path: str | Path) -> Path:
150
+ """Write this configuration to a YAML file and return the path."""
151
+ path = Path(path)
152
+ path.parent.mkdir(parents=True, exist_ok=True)
153
+ data = self.model_dump(mode="json")
154
+ path.write_text(
155
+ yaml.safe_dump(data, sort_keys=False, default_flow_style=False),
156
+ encoding="utf-8",
157
+ )
158
+ return path
159
+
160
+
161
+ def load_config(path: str | Path) -> ExperimentConfig:
162
+ """Convenience wrapper for :meth:`ExperimentConfig.from_yaml`."""
163
+ return ExperimentConfig.from_yaml(path)
e2am/exceptions.py ADDED
@@ -0,0 +1,45 @@
1
+ """Exception hierarchy for E2AM.
2
+
3
+ All E2AM exceptions inherit from :class:`E2AMError`, so callers can catch a
4
+ single base class. Modules raise the most specific subclass available.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ __all__ = [
10
+ "ConfigError",
11
+ "E2AMError",
12
+ "HardwareError",
13
+ "MonitorError",
14
+ "ProfilerError",
15
+ "ReportError",
16
+ "TrainerError",
17
+ ]
18
+
19
+
20
+ class E2AMError(Exception):
21
+ """Base class for all E2AM errors."""
22
+
23
+
24
+ class ConfigError(E2AMError):
25
+ """Raised when configuration is invalid or cannot be loaded."""
26
+
27
+
28
+ class HardwareError(E2AMError):
29
+ """Raised when hardware detection or access fails unexpectedly."""
30
+
31
+
32
+ class MonitorError(E2AMError):
33
+ """Raised when the monitoring session cannot start or record data."""
34
+
35
+
36
+ class ProfilerError(E2AMError):
37
+ """Raised when model profiling (FLOPs, latency, memory) fails."""
38
+
39
+
40
+ class TrainerError(E2AMError):
41
+ """Raised when the training loop is misconfigured or fails."""
42
+
43
+
44
+ class ReportError(E2AMError):
45
+ """Raised when report generation fails."""