alag 0.1.0__py3-none-win_amd64.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.
- alag/__init__.py +6 -0
- alag/logger.py +336 -0
- alag-0.1.0.data/scripts/alag.exe +0 -0
- alag-0.1.0.dist-info/METADATA +201 -0
- alag-0.1.0.dist-info/RECORD +8 -0
- alag-0.1.0.dist-info/WHEEL +4 -0
- alag-0.1.0.dist-info/licenses/LICENSE +21 -0
- alag-0.1.0.dist-info/sboms/alag.cyclonedx.json +57 -0
alag/__init__.py
ADDED
alag/logger.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""The ALAG logger.
|
|
2
|
+
|
|
3
|
+
``log()`` runs inside the training loop, so it does no I/O, no formatting and
|
|
4
|
+
takes no lock. It puts raw values on a deque and returns. A daemon thread does
|
|
5
|
+
the formatting and the disk writes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import atexit
|
|
9
|
+
import collections
|
|
10
|
+
import csv
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
import weakref
|
|
18
|
+
|
|
19
|
+
_FLUSH_INTERVAL = 1.0 # seconds between background writes
|
|
20
|
+
_SYSTEM_INTERVAL = 10.0 # seconds between system metric samples
|
|
21
|
+
_GPU_FIELDS = (
|
|
22
|
+
("utilization.gpu", "util_percent"),
|
|
23
|
+
("memory.used", "memory_gb"),
|
|
24
|
+
("temperature.gpu", "temperature_c"),
|
|
25
|
+
("power.draw", "power_w"),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _check_segment(kind, value):
|
|
30
|
+
if not value or value in (".", "..") or "/" in value or "\\" in value:
|
|
31
|
+
raise ValueError(f"invalid {kind} name: {value!r}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _check_image_name(name):
|
|
35
|
+
parts = name.split("/")
|
|
36
|
+
if "\\" in name or any(p in ("", ".", "..") for p in parts):
|
|
37
|
+
raise ValueError(f"invalid image name: {name!r}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _update_json(path, default, change):
|
|
41
|
+
"""Rewrite a small JSON file atomically, so the viewer never reads half of it."""
|
|
42
|
+
try:
|
|
43
|
+
with open(path) as f:
|
|
44
|
+
data = json.load(f)
|
|
45
|
+
except (FileNotFoundError, ValueError):
|
|
46
|
+
data = default
|
|
47
|
+
data = change(data)
|
|
48
|
+
tmp = f"{path}.{os.getpid()}.tmp"
|
|
49
|
+
with open(tmp, "w") as f:
|
|
50
|
+
json.dump(data, f, indent=2)
|
|
51
|
+
os.replace(tmp, path)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _parse_gpus(text):
|
|
55
|
+
"""Turn nvidia-smi CSV output into metrics, skipping fields shown as [N/A]."""
|
|
56
|
+
stats = {}
|
|
57
|
+
for index, line in enumerate(text.strip().splitlines()):
|
|
58
|
+
for (_, name), value in zip(_GPU_FIELDS, line.split(",")):
|
|
59
|
+
try:
|
|
60
|
+
number = float(value)
|
|
61
|
+
except ValueError:
|
|
62
|
+
continue
|
|
63
|
+
stats[f"system/gpu{index}_{name}"] = (
|
|
64
|
+
number / 1024 if name == "memory_gb" else number
|
|
65
|
+
)
|
|
66
|
+
return stats
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _read_gpus():
|
|
70
|
+
"""Return GPU metrics, or None if nvidia-smi isn't usable on this machine."""
|
|
71
|
+
query = ",".join(field for field, _ in _GPU_FIELDS)
|
|
72
|
+
try:
|
|
73
|
+
result = subprocess.run(
|
|
74
|
+
["nvidia-smi", f"--query-gpu={query}", "--format=csv,noheader,nounits"],
|
|
75
|
+
capture_output=True,
|
|
76
|
+
text=True,
|
|
77
|
+
timeout=5,
|
|
78
|
+
)
|
|
79
|
+
except (OSError, subprocess.SubprocessError):
|
|
80
|
+
return None
|
|
81
|
+
return _parse_gpus(result.stdout) if result.returncode == 0 else None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _new_experiment_dir(parent, experiment):
|
|
85
|
+
"""Create a fresh folder, adding -2, -3... if the name is already used."""
|
|
86
|
+
os.makedirs(parent, exist_ok=True)
|
|
87
|
+
name, n = experiment, 1
|
|
88
|
+
while True:
|
|
89
|
+
path = os.path.join(parent, name)
|
|
90
|
+
try:
|
|
91
|
+
os.mkdir(path)
|
|
92
|
+
return path
|
|
93
|
+
except FileExistsError:
|
|
94
|
+
n += 1
|
|
95
|
+
name = f"{experiment}-{n}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ALAG:
|
|
99
|
+
"""Logs one experiment to ``<root>/<group>/<experiment>/``.
|
|
100
|
+
|
|
101
|
+
If that experiment already exists, a new one is created with a numeric
|
|
102
|
+
suffix, so re-running a script never mixes two runs in the same charts.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
group,
|
|
108
|
+
experiment,
|
|
109
|
+
config=None,
|
|
110
|
+
root="logs",
|
|
111
|
+
tags=None,
|
|
112
|
+
note=None,
|
|
113
|
+
system_metrics=False,
|
|
114
|
+
):
|
|
115
|
+
_check_segment("group", group)
|
|
116
|
+
_check_segment("experiment", experiment)
|
|
117
|
+
|
|
118
|
+
psutil = None
|
|
119
|
+
if system_metrics: # checked first, so a failure leaves no empty folder
|
|
120
|
+
try:
|
|
121
|
+
import psutil
|
|
122
|
+
except ImportError:
|
|
123
|
+
raise ImportError(
|
|
124
|
+
"system_metrics=True needs psutil: pip install 'alag[system]'"
|
|
125
|
+
) from None
|
|
126
|
+
|
|
127
|
+
self.path = _new_experiment_dir(os.path.join(root, group), experiment)
|
|
128
|
+
self.experiment = os.path.basename(self.path)
|
|
129
|
+
self.group = group
|
|
130
|
+
self._group_path = os.path.join(root, group)
|
|
131
|
+
|
|
132
|
+
with open(os.path.join(self.path, "config.json"), "w") as f:
|
|
133
|
+
json.dump(config or {}, f, indent=2, default=str)
|
|
134
|
+
|
|
135
|
+
if tags:
|
|
136
|
+
self.tag(*tags)
|
|
137
|
+
if note is not None:
|
|
138
|
+
self.note(note)
|
|
139
|
+
|
|
140
|
+
self._file = open(
|
|
141
|
+
os.path.join(self.path, "metrics.csv"), "w", newline="", buffering=1 << 16
|
|
142
|
+
)
|
|
143
|
+
self._writer = csv.writer(self._file, lineterminator="\n")
|
|
144
|
+
self._writer.writerow(("step", "time", "metric", "value"))
|
|
145
|
+
self._file.flush()
|
|
146
|
+
|
|
147
|
+
self._queue = collections.deque()
|
|
148
|
+
self._auto_step = 0
|
|
149
|
+
self._stop = threading.Event()
|
|
150
|
+
self._write_lock = threading.Lock()
|
|
151
|
+
self._closed = False
|
|
152
|
+
|
|
153
|
+
self._thread = threading.Thread(
|
|
154
|
+
target=self._run, name="alag-writer", daemon=True
|
|
155
|
+
)
|
|
156
|
+
self._thread.start()
|
|
157
|
+
|
|
158
|
+
self._system_thread = None
|
|
159
|
+
if psutil is not None:
|
|
160
|
+
self._system_thread = threading.Thread(
|
|
161
|
+
target=self._watch_system, args=(psutil,), name="alag-system", daemon=True
|
|
162
|
+
)
|
|
163
|
+
self._system_thread.start()
|
|
164
|
+
|
|
165
|
+
# A weak reference, so atexit doesn't keep every logger alive.
|
|
166
|
+
ref = weakref.ref(self)
|
|
167
|
+
self._atexit = lambda: (lambda s: s and s.close())(ref())
|
|
168
|
+
atexit.register(self._atexit)
|
|
169
|
+
|
|
170
|
+
def log(self, metrics, step=None):
|
|
171
|
+
"""Queue scalars. Values may be numbers or 0-d tensors.
|
|
172
|
+
|
|
173
|
+
Tensors are detached here, not read: reading a GPU tensor would make
|
|
174
|
+
the training loop wait for the GPU. The writer thread reads them.
|
|
175
|
+
"""
|
|
176
|
+
if step is None:
|
|
177
|
+
step = self._auto_step
|
|
178
|
+
self._auto_step = step + 1
|
|
179
|
+
|
|
180
|
+
items = list(metrics.items())
|
|
181
|
+
for i, (key, value) in enumerate(items):
|
|
182
|
+
kind = type(value)
|
|
183
|
+
if kind is not float and kind is not int:
|
|
184
|
+
detach = getattr(value, "detach", None)
|
|
185
|
+
if detach is not None:
|
|
186
|
+
items[i] = (key, detach())
|
|
187
|
+
self._queue.append((step, time.time(), items))
|
|
188
|
+
|
|
189
|
+
def log_image(self, name, image, step=None):
|
|
190
|
+
"""Save an image from a file path or a matplotlib figure.
|
|
191
|
+
|
|
192
|
+
This one runs on the calling thread: matplotlib isn't thread-safe, and
|
|
193
|
+
a path may be overwritten as soon as this returns.
|
|
194
|
+
"""
|
|
195
|
+
_check_image_name(name)
|
|
196
|
+
if step is None:
|
|
197
|
+
step = max(self._auto_step - 1, 0)
|
|
198
|
+
|
|
199
|
+
folder = os.path.join(self.path, "images", *name.split("/"))
|
|
200
|
+
os.makedirs(folder, exist_ok=True)
|
|
201
|
+
|
|
202
|
+
if isinstance(image, (str, os.PathLike)):
|
|
203
|
+
ext = os.path.splitext(os.fspath(image))[1].lower() or ".png"
|
|
204
|
+
final = os.path.join(folder, f"{step}{ext}")
|
|
205
|
+
tmp = final + ".tmp"
|
|
206
|
+
shutil.copyfile(image, tmp)
|
|
207
|
+
elif hasattr(image, "savefig"):
|
|
208
|
+
final = os.path.join(folder, f"{step}.png")
|
|
209
|
+
tmp = final + ".tmp"
|
|
210
|
+
image.savefig(tmp, format="png", bbox_inches="tight")
|
|
211
|
+
else:
|
|
212
|
+
raise TypeError("image must be a file path or a matplotlib figure")
|
|
213
|
+
os.replace(tmp, final) # the viewer never sees a half-written image
|
|
214
|
+
|
|
215
|
+
def tag(self, *tags):
|
|
216
|
+
"""Add tags to this experiment. They show up in the viewer's left panel."""
|
|
217
|
+
|
|
218
|
+
def change(meta):
|
|
219
|
+
current = meta.get("tags", [])
|
|
220
|
+
meta["tags"] = current + [t for t in tags if t not in current]
|
|
221
|
+
return meta
|
|
222
|
+
|
|
223
|
+
_update_json(os.path.join(self.path, "meta.json"), {}, change)
|
|
224
|
+
|
|
225
|
+
def note(self, text):
|
|
226
|
+
"""Set this experiment's note, replacing any earlier one."""
|
|
227
|
+
|
|
228
|
+
def change(meta):
|
|
229
|
+
meta["note"] = text
|
|
230
|
+
return meta
|
|
231
|
+
|
|
232
|
+
_update_json(os.path.join(self.path, "meta.json"), {}, change)
|
|
233
|
+
|
|
234
|
+
def flag(self, metric, by_step, below=None, above=None):
|
|
235
|
+
"""Set a target for this group: ``metric`` should cross a value by a step.
|
|
236
|
+
|
|
237
|
+
``logger.flag("val/loss", below=0.5, by_step=1000)`` marks that point on
|
|
238
|
+
the chart, and the viewer reports which experiments reached it in time.
|
|
239
|
+
"""
|
|
240
|
+
if (below is None) == (above is None):
|
|
241
|
+
raise ValueError("pass exactly one of below= or above=")
|
|
242
|
+
flag = {
|
|
243
|
+
"metric": metric,
|
|
244
|
+
"direction": "below" if above is None else "above",
|
|
245
|
+
"value": below if above is None else above,
|
|
246
|
+
"step": by_step,
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
def change(flags):
|
|
250
|
+
return flags if flag in flags else flags + [flag]
|
|
251
|
+
|
|
252
|
+
_update_json(os.path.join(self._group_path, "flags.json"), [], change)
|
|
253
|
+
|
|
254
|
+
def section(self, name, *metrics):
|
|
255
|
+
"""Show these metrics' charts under a heading, for the whole group.
|
|
256
|
+
|
|
257
|
+
``logger.section("validation", "val/loss", "bleu")``. Metrics without a
|
|
258
|
+
section go under the part of their name before the first slash.
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
def change(layout):
|
|
262
|
+
layout.setdefault("sections", {}).update(dict.fromkeys(metrics, name))
|
|
263
|
+
return layout
|
|
264
|
+
|
|
265
|
+
_update_json(os.path.join(self._group_path, "layout.json"), {}, change)
|
|
266
|
+
|
|
267
|
+
def pin(self, *names):
|
|
268
|
+
"""Keep these charts or images at the top of the page, for the whole group."""
|
|
269
|
+
|
|
270
|
+
def change(layout):
|
|
271
|
+
pinned = layout.get("pinned", [])
|
|
272
|
+
layout["pinned"] = pinned + [n for n in names if n not in pinned]
|
|
273
|
+
return layout
|
|
274
|
+
|
|
275
|
+
_update_json(os.path.join(self._group_path, "layout.json"), {}, change)
|
|
276
|
+
|
|
277
|
+
def flush(self):
|
|
278
|
+
"""Write everything queued so far to disk."""
|
|
279
|
+
with self._write_lock:
|
|
280
|
+
self._drain()
|
|
281
|
+
|
|
282
|
+
def close(self):
|
|
283
|
+
"""Flush and stop. Runs automatically when the program exits."""
|
|
284
|
+
if self._closed:
|
|
285
|
+
return
|
|
286
|
+
self._closed = True
|
|
287
|
+
self._stop.set()
|
|
288
|
+
self._thread.join()
|
|
289
|
+
if self._system_thread is not None:
|
|
290
|
+
self._system_thread.join()
|
|
291
|
+
self.flush()
|
|
292
|
+
self._file.close()
|
|
293
|
+
atexit.unregister(self._atexit)
|
|
294
|
+
|
|
295
|
+
def __enter__(self):
|
|
296
|
+
return self
|
|
297
|
+
|
|
298
|
+
def __exit__(self, *exc):
|
|
299
|
+
self.close()
|
|
300
|
+
|
|
301
|
+
def _run(self):
|
|
302
|
+
while not self._stop.wait(_FLUSH_INTERVAL):
|
|
303
|
+
self.flush()
|
|
304
|
+
|
|
305
|
+
def _watch_system(self, psutil):
|
|
306
|
+
"""Sample on this thread, so nothing here touches the training loop."""
|
|
307
|
+
process = psutil.Process()
|
|
308
|
+
psutil.cpu_percent() # the first reading is meaningless, it starts the count
|
|
309
|
+
gpus = True
|
|
310
|
+
while not self._stop.wait(_SYSTEM_INTERVAL):
|
|
311
|
+
items = [
|
|
312
|
+
("system/cpu_percent", psutil.cpu_percent()),
|
|
313
|
+
("system/ram_used_gb", psutil.virtual_memory().used / 2**30),
|
|
314
|
+
("system/process_ram_gb", process.memory_info().rss / 2**30),
|
|
315
|
+
]
|
|
316
|
+
if gpus:
|
|
317
|
+
stats = _read_gpus()
|
|
318
|
+
gpus = stats is not None
|
|
319
|
+
items.extend((stats or {}).items())
|
|
320
|
+
step = max(self._auto_step - 1, 0)
|
|
321
|
+
self._queue.append((step, time.time(), items))
|
|
322
|
+
|
|
323
|
+
def _drain(self):
|
|
324
|
+
queue, writer = self._queue, self._writer
|
|
325
|
+
wrote = False
|
|
326
|
+
while queue:
|
|
327
|
+
step, when, items = queue.popleft()
|
|
328
|
+
for key, value in items:
|
|
329
|
+
try:
|
|
330
|
+
value = float(value) # blocks here for a GPU tensor
|
|
331
|
+
except (TypeError, ValueError):
|
|
332
|
+
continue # not a scalar, so there's nothing to chart
|
|
333
|
+
writer.writerow((step, f"{when:.3f}", key, value))
|
|
334
|
+
wrote = True
|
|
335
|
+
if wrote:
|
|
336
|
+
self._file.flush()
|
|
Binary file
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: alag
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Classifier: Development Status :: 3 - Alpha
|
|
5
|
+
Classifier: Intended Audience :: Science/Research
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Programming Language :: Rust
|
|
8
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
9
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
10
|
+
Requires-Dist: psutil ; extra == 'system'
|
|
11
|
+
Provides-Extra: system
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Summary: A simple logging and graphing tool for researchers
|
|
14
|
+
Keywords: experiment tracking,logging,machine learning,plotting
|
|
15
|
+
Author: Sandeep S
|
|
16
|
+
License-Expression: MIT
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
19
|
+
Project-URL: Issues, https://github.com/SandyTheAdventurer/alag/issues
|
|
20
|
+
Project-URL: Repository, https://github.com/SandyTheAdventurer/alag
|
|
21
|
+
|
|
22
|
+
# ALAG
|
|
23
|
+
|
|
24
|
+
**A** simple **L**ogging **A**nd **G**raphing tool, for researchers.
|
|
25
|
+
|
|
26
|
+
## Why
|
|
27
|
+
|
|
28
|
+
Existing experiment trackers ask for more than a researcher who wants to log a run and look at the results should have to give:
|
|
29
|
+
|
|
30
|
+
- **Weights & Biases** requires an account and other setup.
|
|
31
|
+
- **MLflow** needs a tracking server that is heavy for this job, and comparing runs in it feels awkward.
|
|
32
|
+
- **TensorBoard** has visualizations that leave a lot to be desired, and makes comparing multiple runs hard.
|
|
33
|
+
|
|
34
|
+
ALAG aims to do two things well, logging and graphing, without the overhead. Comparing multiple runs is a core feature.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
pip install alag
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
This gives you the `ALAG` class for your training code and the `alag` command for viewing results.
|
|
43
|
+
|
|
44
|
+
Wheels are built for Linux (x86-64 and ARM, glibc 2.17 or newer) and Windows. On macOS, pip builds ALAG from source, which needs [Rust](https://rustup.rs).
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
Log from your training code:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from alag import ALAG
|
|
52
|
+
|
|
53
|
+
logger = ALAG(group="my-group", experiment="my-experiment", config={"lr": 3e-4})
|
|
54
|
+
|
|
55
|
+
logger.log({"train/loss": 0.42}, step=100)
|
|
56
|
+
logger.log_image("confusion-matrix", "confusion.png", step=100) # from a file
|
|
57
|
+
logger.log_image("loss-curve", fig, step=100) # from a matplotlib figure
|
|
58
|
+
|
|
59
|
+
logger.tag("baseline", "ablation")
|
|
60
|
+
logger.note("Loss spikes after the LR warmup ends")
|
|
61
|
+
logger.flag("val/loss", below=0.5, by_step=1000)
|
|
62
|
+
logger.section("validation", "val/loss", "bleu")
|
|
63
|
+
logger.pin("val/loss")
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`ALAG` also takes `tags=[...]`, `note="..."` and `system_metrics=True` directly.
|
|
67
|
+
|
|
68
|
+
Then view your experiments:
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
alag # serves ./logs at http://localhost:6100
|
|
72
|
+
alag path/to/logs --port 8000
|
|
73
|
+
alag --export report.html # one HTML file to send to someone
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
If an experiment with that name already exists in the group, ALAG creates a new one with a number on the end, such as `my-experiment-2`, so a re-run never mixes with earlier results.
|
|
77
|
+
|
|
78
|
+
To view experiments on a remote machine, run `alag` there and forward the port with `ssh -L 6100:localhost:6100 my-server`.
|
|
79
|
+
|
|
80
|
+
## Speed
|
|
81
|
+
|
|
82
|
+
`logger.log` is built to stay out of your training loop. It only puts the values on an in-memory queue and returns. A background thread formats them and writes to disk about once a second.
|
|
83
|
+
|
|
84
|
+
You can pass GPU tensors straight to `logger.log`. The background thread reads them, so your training loop doesn't wait for the GPU the way it does with `loss.item()`.
|
|
85
|
+
|
|
86
|
+
On an RTX 3050 laptop GPU, a `logger.log` call with a GPU tensor took about 15 µs. A 6 ms training step was no slower with logging than without it, with or without system metrics. Any difference was smaller than the variation between runs.
|
|
87
|
+
|
|
88
|
+
`logger.log_image` does its work immediately, because matplotlib isn't thread-safe. Log images occasionally, not on every step.
|
|
89
|
+
|
|
90
|
+
Queued values are written when your program exits, including after an exception. If the process is killed outright, you can lose about the last second of values.
|
|
91
|
+
|
|
92
|
+
## What it logs
|
|
93
|
+
|
|
94
|
+
- **Scalars**, such as loss and accuracy.
|
|
95
|
+
- **Images**, such as custom graphs you make yourself.
|
|
96
|
+
- **Hyperparameters**, from the `config` you pass to `ALAG`.
|
|
97
|
+
- **System metrics**, if you turn them on.
|
|
98
|
+
|
|
99
|
+
### System metrics
|
|
100
|
+
|
|
101
|
+
These are off by default. To turn them on, install the extra and pass `system_metrics=True`:
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
pip install 'alag[system]'
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
logger = ALAG(group="my-group", experiment="my-experiment", system_metrics=True)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Every 10 seconds, a background thread logs CPU use, system RAM and your process's RAM under `system/`. If `nvidia-smi` is available, it also logs utilization, memory, temperature and power for each GPU. GPU memory is the total in use on that card, including other programs.
|
|
112
|
+
|
|
113
|
+
Each sample uses your latest training step. Before your first `logger.log`, or during a long step, several samples share a step, so set **X axis** to **Time** to read these charts.
|
|
114
|
+
|
|
115
|
+
## Storage
|
|
116
|
+
|
|
117
|
+
Experiments are organized into groups. Each experiment is a plain folder inside its group's folder. There is no database or account.
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
logs/
|
|
121
|
+
└── my-group/
|
|
122
|
+
├── flags.json # targets shared by the group
|
|
123
|
+
├── layout.json # chart sections and pins shared by the group
|
|
124
|
+
└── my-experiment/
|
|
125
|
+
├── config.json # the experiment's settings
|
|
126
|
+
├── metrics.csv # every scalar, appended as it is logged
|
|
127
|
+
├── meta.json # tags and note
|
|
128
|
+
└── images/ # logged images
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`metrics.csv` has one row per logged value, so an experiment can add new metrics at any point and metrics logged at different intervals don't leave empty cells:
|
|
132
|
+
|
|
133
|
+
```csv
|
|
134
|
+
step,time,metric,value
|
|
135
|
+
100,1762444800.1,train/loss,0.42
|
|
136
|
+
100,1762444800.1,train/acc,0.81
|
|
137
|
+
200,1762444812.6,train/loss,0.37
|
|
138
|
+
200,1762444812.7,val/loss,0.45
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Viewing experiments
|
|
142
|
+
|
|
143
|
+
The `alag` command is a small compiled Rust program that generates an HTML page and serves it on localhost. The page updates automatically while your experiments are training.
|
|
144
|
+
|
|
145
|
+
A panel on the left lists your groups and the experiments inside them. Select experiments in the panel to compare them: each metric gets one chart, with a line for every selected experiment.
|
|
146
|
+
|
|
147
|
+
### Hyperparameters
|
|
148
|
+
|
|
149
|
+
The **Hyperparameters** tab has a row for each selected experiment and a column for each setting in its `config`. Settings that differ come first, in bold. Click a column to sort by it, or tick **Only settings that differ** to hide the rest.
|
|
150
|
+
|
|
151
|
+
### Sections and pins
|
|
152
|
+
|
|
153
|
+
Charts are grouped into sections by the part of the metric name before the first slash, so `val/loss` goes under `val` and `system/cpu_percent` goes under `system`.
|
|
154
|
+
|
|
155
|
+
To choose a section yourself, use **Section** on a chart or `logger.section`. **Pin** on a chart or image, or `logger.pin`, moves it to the top of its tab.
|
|
156
|
+
|
|
157
|
+
Sections and pins apply to the whole group, so they stay in place when you compare its experiments.
|
|
158
|
+
|
|
159
|
+
### Mean and std over seeds
|
|
160
|
+
|
|
161
|
+
Tick **Mean ± std over seeds** to merge selected experiments into one line with a shaded band. Experiments merge when they are in the same group and their configs differ only by a key named `seed`. Experiments with no `seed` in their config stay separate.
|
|
162
|
+
|
|
163
|
+
The band is one sample standard deviation at each step. Smoothing is applied to each seed before merging.
|
|
164
|
+
|
|
165
|
+
### Flags
|
|
166
|
+
|
|
167
|
+
A flag sets a target, such as "`val/loss` should go below 0.5 by step 1,000". Set one with `logger.flag`, or with **Flag** on a chart, where clicking the chart fills in the values.
|
|
168
|
+
|
|
169
|
+
The flag is drawn on the chart, and each experiment in the group is listed as reached, missed or not yet. Verdicts use the line as plotted, so they follow your smoothing and seed settings.
|
|
170
|
+
|
|
171
|
+
### Notes and tags
|
|
172
|
+
|
|
173
|
+
Add these in the **Notes** tab or from Python. Tags appear in the left panel, and the filter box searches names, tags and notes.
|
|
174
|
+
|
|
175
|
+
### Exporting charts
|
|
176
|
+
|
|
177
|
+
Each chart has **SVG**, **PNG**, **PDF**, **CSV** and **LaTeX** buttons. Exports use a white background and match the current view, including every selected experiment, smoothing, zoom, log scale and flags.
|
|
178
|
+
|
|
179
|
+
**LaTeX** gives you a `pgfplots` figure to copy and paste. The data is included, so there's no file to upload, but long runs are thinned to keep it small enough for pdflatex. Your document needs:
|
|
180
|
+
|
|
181
|
+
```latex
|
|
182
|
+
\usepackage{pgfplots}
|
|
183
|
+
\pgfplotsset{compat=1.18}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Sharing a report
|
|
187
|
+
|
|
188
|
+
`alag --export report.html` writes one HTML file that opens in any browser, with no server or install. It opens on the most recently updated group, with its experiments selected.
|
|
189
|
+
|
|
190
|
+
You can still select experiments, zoom, smooth, merge seeds and export charts. It's a read-only snapshot, so it doesn't update, and notes, tags, flags, sections and pins can't be changed.
|
|
191
|
+
|
|
192
|
+
The file contains everything in the log folder, including every group, config, note and image. To share only part of it, export a folder with just those groups. Runs with many images can make the file large.
|
|
193
|
+
|
|
194
|
+
## Building wheels
|
|
195
|
+
|
|
196
|
+
`scripts/build-wheels.sh` builds the source package and every wheel into `dist/`. It needs only Docker.
|
|
197
|
+
|
|
198
|
+
## License
|
|
199
|
+
|
|
200
|
+
MIT
|
|
201
|
+
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
alag/__init__.py,sha256=odLgkMdpz1fJM24SViGpytVRLkT6FWjNJIGxc-MbUb0,132
|
|
2
|
+
alag/logger.py,sha256=QP1ggtu3b0PVO7yXK-R7WkNCU6pBx3Fq40ajc8vB_ps,11463
|
|
3
|
+
alag-0.1.0.data/scripts/alag.exe,sha256=H4TdtciyaFD6JjiCJg12x7wakRmIXVLiBLJ2hLg80u0,452608
|
|
4
|
+
alag-0.1.0.dist-info/METADATA,sha256=xeg9R0R3YBzRTLWNnAQeJivzx-f_86ITGKHoXkjAVBM,8923
|
|
5
|
+
alag-0.1.0.dist-info/WHEEL,sha256=8Aej0W0a6Cz6apA3IzJrTnxLRVLAt-w0Oh8SA3Con_c,94
|
|
6
|
+
alag-0.1.0.dist-info/licenses/LICENSE,sha256=MJF8cEazYYmeI8Ol3KH2xvCIH0X0qgDEplEjxhRZB6Y,1066
|
|
7
|
+
alag-0.1.0.dist-info/sboms/alag.cyclonedx.json,sha256=EN0MyLrtEFmGxtKlveWLsr5nAc_r9NneGypbdANTjQQ,1373
|
|
8
|
+
alag-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sandeep S
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"bomFormat": "CycloneDX",
|
|
3
|
+
"specVersion": "1.5",
|
|
4
|
+
"version": 1,
|
|
5
|
+
"serialNumber": "urn:uuid:d96090c6-b0ff-49b7-9050-fa7dc1967c02",
|
|
6
|
+
"metadata": {
|
|
7
|
+
"timestamp": "2026-11-07T15:31:24.158007241Z",
|
|
8
|
+
"tools": [
|
|
9
|
+
{
|
|
10
|
+
"vendor": "CycloneDX",
|
|
11
|
+
"name": "cargo-cyclonedx",
|
|
12
|
+
"version": "0.5.9"
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"component": {
|
|
16
|
+
"type": "application",
|
|
17
|
+
"bom-ref": "path+file:///io#alag@0.1.0",
|
|
18
|
+
"name": "alag",
|
|
19
|
+
"version": "0.1.0",
|
|
20
|
+
"description": "A simple logging and graphing tool for researchers",
|
|
21
|
+
"scope": "required",
|
|
22
|
+
"licenses": [
|
|
23
|
+
{
|
|
24
|
+
"expression": "MIT"
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"purl": "pkg:cargo/alag@0.1.0?download_url=file://.",
|
|
28
|
+
"externalReferences": [
|
|
29
|
+
{
|
|
30
|
+
"type": "vcs",
|
|
31
|
+
"url": "https://github.com/SandyTheAdventurer/alag"
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
"components": [
|
|
35
|
+
{
|
|
36
|
+
"type": "application",
|
|
37
|
+
"bom-ref": "path+file:///io#alag@0.1.0 bin-target-0",
|
|
38
|
+
"name": "alag",
|
|
39
|
+
"version": "0.1.0",
|
|
40
|
+
"purl": "pkg:cargo/alag@0.1.0?download_url=file://.#src/main.rs"
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
"properties": [
|
|
45
|
+
{
|
|
46
|
+
"name": "cdx:rustc:sbom:target:all_targets",
|
|
47
|
+
"value": "true"
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
"components": [],
|
|
52
|
+
"dependencies": [
|
|
53
|
+
{
|
|
54
|
+
"ref": "path+file:///io#alag@0.1.0"
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
}
|