exptrail 0.1.0__tar.gz
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.
- exptrail-0.1.0/LICENSE +21 -0
- exptrail-0.1.0/MANIFEST.in +1 -0
- exptrail-0.1.0/PKG-INFO +200 -0
- exptrail-0.1.0/README.md +166 -0
- exptrail-0.1.0/pyproject.toml +48 -0
- exptrail-0.1.0/setup.cfg +4 -0
- exptrail-0.1.0/src/exptrail/__init__.py +7 -0
- exptrail-0.1.0/src/exptrail/__main__.py +5 -0
- exptrail-0.1.0/src/exptrail/_stack.py +27 -0
- exptrail-0.1.0/src/exptrail/cli.py +210 -0
- exptrail-0.1.0/src/exptrail/liveness.py +271 -0
- exptrail-0.1.0/src/exptrail/meta.py +352 -0
- exptrail-0.1.0/src/exptrail/plot.py +38 -0
- exptrail-0.1.0/src/exptrail/readme.py +294 -0
- exptrail-0.1.0/src/exptrail/run.py +481 -0
- exptrail-0.1.0/src/exptrail/store.py +134 -0
- exptrail-0.1.0/src/exptrail.egg-info/PKG-INFO +200 -0
- exptrail-0.1.0/src/exptrail.egg-info/SOURCES.txt +27 -0
- exptrail-0.1.0/src/exptrail.egg-info/dependency_links.txt +1 -0
- exptrail-0.1.0/src/exptrail.egg-info/entry_points.txt +2 -0
- exptrail-0.1.0/src/exptrail.egg-info/requires.txt +8 -0
- exptrail-0.1.0/src/exptrail.egg-info/top_level.txt +1 -0
- exptrail-0.1.0/tests/__init__.py +0 -0
- exptrail-0.1.0/tests/conftest.py +39 -0
- exptrail-0.1.0/tests/test_cli.py +67 -0
- exptrail-0.1.0/tests/test_dirty.py +384 -0
- exptrail-0.1.0/tests/test_liveness.py +575 -0
- exptrail-0.1.0/tests/test_readme.py +280 -0
- exptrail-0.1.0/tests/test_run.py +299 -0
exptrail-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jaden Isaac
|
|
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 @@
|
|
|
1
|
+
recursive-include tests *.py
|
exptrail-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: exptrail
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local, framework-agnostic ML experiment logging that keeps README numbers verifiable.
|
|
5
|
+
Author: Jaden Isaac
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jadenisaac2005/exptrail
|
|
8
|
+
Project-URL: Source, https://github.com/jadenisaac2005/exptrail
|
|
9
|
+
Project-URL: Issues, https://github.com/jadenisaac2005/exptrail/issues
|
|
10
|
+
Keywords: machine-learning,experiment-tracking,reproducibility,logging,mlops
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
23
|
+
Classifier: Topic :: System :: Logging
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Provides-Extra: plot
|
|
28
|
+
Requires-Dist: matplotlib; extra == "plot"
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
32
|
+
Requires-Dist: matplotlib; extra == "dev"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# exptrail
|
|
36
|
+
|
|
37
|
+
Local, framework-agnostic experiment logging so that **every number in your README can be traced to a saved run and re-checked**.
|
|
38
|
+
|
|
39
|
+
README results tables drift: configs get overwritten, loss curves aren't saved, and nobody remembers which commit produced 0.9807. `exptrail` writes each run to its own folder (config, per-step metrics, final numbers, git commit + diff, environment), generates the README table *from those folders*, and `exptrail verify` fails CI if the table and the runs disagree.
|
|
40
|
+
|
|
41
|
+
- Standard library only (Python 3.10+). `matplotlib` optional, for `plot`.
|
|
42
|
+
- Works with NumPy-from-scratch, PyTorch, sklearn, anything: you just pass numbers.
|
|
43
|
+
- Works without git (e.g. Colab): recorded as `no-git` with a warning, never an error.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install exptrail # or: pip install "exptrail[plot]"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Log a run
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from exptrail import Run
|
|
55
|
+
|
|
56
|
+
with Run(name="momentum-lr0.01", config={"lr": 0.01, "momentum": 0.9},
|
|
57
|
+
tags=["digits"], seeds={"numpy": 0}) as run:
|
|
58
|
+
for epoch in range(30):
|
|
59
|
+
...
|
|
60
|
+
run.log(step=epoch, train_loss=loss, val_acc=acc) # appended + flushed immediately
|
|
61
|
+
run.summary(test_acc=0.9807) # the numbers you report
|
|
62
|
+
run.save_artifact("model.npz") # copied into the run folder
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Or as a decorator. The function's arguments become the config, a `run` parameter receives the active run, and a returned dict becomes the summary:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from exptrail import track
|
|
69
|
+
|
|
70
|
+
@track(name="mlp")
|
|
71
|
+
def train(lr=0.01, epochs=30, run=None):
|
|
72
|
+
...
|
|
73
|
+
return {"test_acc": acc}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
If one `with` block doesn't fit your script (a notebook, a training loop spread over several functions), start and finish the run yourself:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
run = Run(name="momentum-lr0.01", config=cfg).start()
|
|
80
|
+
... # train, run.log(...), run.summary(...)
|
|
81
|
+
run.finish() # or run.finish("failed"); calling it twice is harmless
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`with Run(...)`, `@track` and `start()`/`finish()` share one code path. Wrap **the whole job**, data loading and evaluation included: `duration_s` only covers the run's own span, and anything that crashes outside it isn't recorded.
|
|
85
|
+
|
|
86
|
+
Each run writes `runs/<UTC timestamp>_<name>/` (override with `root=` or `$EXPTRAIL_ROOT`):
|
|
87
|
+
|
|
88
|
+
| file | contents |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `config.json` | the config you passed |
|
|
91
|
+
| `metrics.csv` | one row per `log()` call: `step`, then your metrics |
|
|
92
|
+
| `summary.json` | final numbers, written on every `summary()` call |
|
|
93
|
+
| `meta.json` | status (see [Run status](#run-status)), git commit/branch/remote/dirty flag (and which files set it), Python + numpy/torch/scikit-learn versions, hostname, argv, cwd, seeds, start/end time, and a `liveness` record (hostname, pid, process start time, boot ID, heartbeat interval) |
|
|
94
|
+
| `heartbeat` | touched every 30 s while the run is alive; removed when it finishes |
|
|
95
|
+
| `git_diff.patch` | uncommitted changes (tracked edits plus new untracked code files), only if there were any |
|
|
96
|
+
| `traceback.txt` | only if the run raised |
|
|
97
|
+
| `artifacts/` | anything passed to `save_artifact()` |
|
|
98
|
+
|
|
99
|
+
Paths in `meta.json` (`cwd`, `python_executable`, `argv`, `git.root`) have your home directory replaced with `~`, so committed runs don't leak your username. Pass `Run(..., redact_paths=False)` to keep them verbatim. Credentials in the git remote URL are always stripped.
|
|
100
|
+
|
|
101
|
+
A dirty working tree triggers a loud `DirtyTreeWarning`: that run's numbers can't be reproduced from a commit, so commit first if you plan to report them.
|
|
102
|
+
|
|
103
|
+
### What counts as dirty
|
|
104
|
+
|
|
105
|
+
- **Modified tracked files** make a run dirty, unless they match `dirty_ignore`.
|
|
106
|
+
- **Untracked files** make a run dirty only if they look like code, i.e. match `untracked_code`. The default patterns are `*.py`, `*.ipynb`, `*.pyx`, `*.yaml`, `*.yml` and `*.toml`. Other untracked files (CSVs, logs, model outputs, the `runs/` folder) are ignored entirely. Files your `.gitignore` excludes never count, and neither does anything inside a virtualenv (a folder containing `pyvenv.cfg`).
|
|
107
|
+
- **`dirty_ignore`** lists glob patterns for changes that shouldn't mark a run dirty, such as a results log you edit by hand. Those changes don't set the flag, don't warn, and don't fail `verify --strict`, but they are still written to `git_diff.patch`, so the record stays complete.
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
Run("momentum-lr0.01", dirty_ignore=["*.md", "submissions/**"],
|
|
111
|
+
untracked_code=["*.py", "*.yaml"]) # replaces the default code patterns
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
A pattern without `/` (`*.md`) matches that file name in any directory. A pattern with `/` (`submissions/**`) matches the path from the repo root, and a trailing `/` (`submissions/`) means everything under that folder.
|
|
115
|
+
|
|
116
|
+
`git_diff.patch` includes untracked code files as new-file diffs, so `git apply git_diff.patch` on the recorded commit rebuilds the tree the run used. Your git index is never touched: the files are added to a temporary copy of it. Untracked files over 1 MB are recorded by path, size and sha256 in `meta.json` (and in the patch header, when there is a patch) instead of being inlined. If such a file is the only change, no `git_diff.patch` is written, since there would be nothing for `git apply` to apply.
|
|
117
|
+
|
|
118
|
+
`meta.json` records what was found under `git`: `dirty_files` (`tracked` and `untracked` lists of the files that made the run dirty), `ignored_files` (changed files that matched `dirty_ignore`), `changed_files` (every changed file that was recorded, in the patch or by hash), `untracked_too_large` (hashed files, if any), and the `dirty_ignore`/`untracked_code` patterns that were in effect.
|
|
119
|
+
|
|
120
|
+
If listing untracked files fails (it times out after 60 s, e.g. on a huge un-ignored data folder or a repo on a network drive), the commit, branch and tracked changes are still recorded, `untracked_scan` says why the scan failed, and the run is treated as dirty because it can't be shown to be clean.
|
|
121
|
+
|
|
122
|
+
On Python 3.11+ you can set the same keys once for the whole repo in `pyproject.toml`. Keyword arguments override it:
|
|
123
|
+
|
|
124
|
+
```toml
|
|
125
|
+
[tool.exptrail]
|
|
126
|
+
dirty_ignore = ["*.md", "submissions/**"]
|
|
127
|
+
untracked_code = ["*.py", "*.ipynb", "*.yaml"]
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Python 3.10 has no built-in TOML parser and exptrail has no dependencies, so on 3.10 a `[tool.exptrail]` section is ignored with a one-time `ConfigWarning`. Pass the keyword arguments instead.
|
|
131
|
+
|
|
132
|
+
## Run status
|
|
133
|
+
|
|
134
|
+
| status | meaning |
|
|
135
|
+
|---|---|
|
|
136
|
+
| `finished` | `finish()` was called, or the `with` block / `@track` function returned normally |
|
|
137
|
+
| `failed` | an exception escaped the run; the traceback is in `traceback.txt` |
|
|
138
|
+
| `interrupted` | Ctrl-C, SIGTERM, or the interpreter exited while the run was started but not finished (`status_reason` says which) |
|
|
139
|
+
| `running` | still going, as far as the evidence shows |
|
|
140
|
+
| `dead` | certainly gone: the process was killed (SIGKILL, the OOM killer), the pid now belongs to another process, or the machine has rebooted |
|
|
141
|
+
| `stale` | probably gone: no heartbeat for a while |
|
|
142
|
+
|
|
143
|
+
The usual ways ML jobs die (SIGKILL, the out-of-memory killer, a Colab disconnect, a laptop losing power) run no cleanup code, so nothing can write "died" into `meta.json`. Instead, a run records who it is when it starts (hostname, boot ID, pid, process start time) and a background thread touches its `heartbeat` file every 30 seconds, whether or not you call `log()`. `ls`, `show`, `compare` and `verify` turn a stored `running` into one of the statuses above when they read the run, and they never write to the run folder:
|
|
144
|
+
|
|
145
|
+
- **On the machine the run started on**, the answer is certain: the process is alive (`running`) or it isn't (`dead (pid gone)`, `dead (pid reused)`, `dead (machine rebooted)`).
|
|
146
|
+
- **From another machine** (a run folder on a shared drive, or synced back from a cluster), or where the process can't be inspected (Windows, runs from older exptrail versions), only the heartbeat's age is available. If it is older than 3 heartbeat intervals the run shows as `stale (last heartbeat 14m ago)`. That is only *probable*: the other machine may be alive but cut off from the shared drive, or its clock may disagree with yours.
|
|
147
|
+
|
|
148
|
+
Heartbeat age is the `heartbeat` file's modification time (mtime). Copying a run folder resets it: `git clone` and `git checkout`, `cp` without `-p`, and cloud sync (Google Drive, Dropbox) all give the file a fresh mtime. So a crashed run folder that was copied or committed and then read on another machine can show as `running (on gpu-1, last heartbeat 5s ago)` for a few minutes, until the copied heartbeat goes stale. `verify` still fails such a run, because its status isn't `finished`.
|
|
149
|
+
|
|
150
|
+
Change the heartbeat interval with `Run(..., heartbeat_s=60)` or `$EXPTRAIL_HEARTBEAT_S`, and the staleness threshold with `$EXPTRAIL_STALE_S` (seconds). On SIGTERM a run is marked `interrupted` before the process exits as it normally would; exptrail only installs its handler when there isn't one already, and restores the previous handler at `finish()`.
|
|
151
|
+
|
|
152
|
+
To make a `dead` or `stale` status permanent, record what happened by hand:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
exptrail mark momentum-lr0.01 interrupted # or failed / finished; notes that it was marked by hand, and when
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`mark` refuses a run that still shows as `running` unless you pass `--force`.
|
|
159
|
+
|
|
160
|
+
## CLI
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
exptrail ls # name, status, key metrics, commit (* = dirty)
|
|
164
|
+
exptrail show momentum-lr0.01 # config + summary + meta
|
|
165
|
+
exptrail compare sgd-lr0.1 momentum-lr0.01 # config diff + metric table
|
|
166
|
+
exptrail plot sgd-lr0.1 momentum-lr0.01 --metric val_acc -o val_acc.png
|
|
167
|
+
exptrail table --metric test_acc --runs '*digits*' --readme README.md
|
|
168
|
+
exptrail verify README.md # non-zero exit on any mismatch
|
|
169
|
+
exptrail mark momentum-lr0.01 interrupted # make a dead/stale status permanent
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Runs can be referred to by folder name, path, run name (latest wins) or a unique substring. `--root DIR` points any command at another runs directory.
|
|
173
|
+
|
|
174
|
+
**`table`** writes a markdown table between `<!-- results:start -->` and `<!-- results:end -->` marker lines (appended if the markers aren't there yet). Markers only count when each is on a line of its own, and there must be exactly one pair. Each row links to the run folder and commit. Re-running it replaces the block; it never duplicates it. Add columns with repeatable `--metric` and `--config` flags. Floats are shown to `--precision` decimals (default 4). Only finished runs are included unless you pass `--include-failed`. Links to run folders are relative to the README; pass `--absolute-links` to link them on GitHub/GitLab instead (needed for a README shown on PyPI, which can't resolve relative links).
|
|
175
|
+
|
|
176
|
+
**`verify`** re-reads every run linked from the block. It fails if a displayed metric doesn't match `summary.json` at the displayed precision, if a config value doesn't exactly match `config.json`, if the commit differs from `meta.json`, if a linked run folder is missing, or if a linked run isn't `finished` (running, dead, stale, failed or interrupted; the message shows the status). Runs that were dirty or git-less produce warnings, and `--strict` turns them into failures. Put it in CI:
|
|
177
|
+
|
|
178
|
+
```yaml
|
|
179
|
+
- run: pip install exptrail && exptrail verify README.md
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
For links in the table to work on GitHub, commit the run folders you report.
|
|
183
|
+
|
|
184
|
+
## Example
|
|
185
|
+
|
|
186
|
+
[`examples/digits`](https://github.com/jadenisaac2005/exptrail/tree/HEAD/examples/digits) trains a NumPy MLP on sklearn's digits with 3 optimiser configs and regenerates its README table. Its results, generated with `exptrail --root examples/digits/runs table --metric test_acc --config lr --config momentum --absolute-links --readme README.md`:
|
|
187
|
+
|
|
188
|
+
<!-- results:start -->
|
|
189
|
+
<!-- Generated by exptrail. Do not edit by hand; `exptrail verify` checks these numbers. -->
|
|
190
|
+
<!-- command: exptrail --root examples/digits/runs table --metric test_acc --config lr --config momentum --absolute-links --readme README.md -->
|
|
191
|
+
| run | lr | momentum | test_acc | commit |
|
|
192
|
+
|---|---|---|---|---|
|
|
193
|
+
| [momentum-lr0.01](https://github.com/jadenisaac2005/exptrail/tree/HEAD/examples/digits/runs/20260924-055909_momentum-lr0.01/) | 0.01 | 0.9 | 0.9611 | [`42124f2`](https://github.com/jadenisaac2005/exptrail/commit/42124f208a0411b96a60bc720978ac602389a53e) |
|
|
194
|
+
| [sgd-lr0.1](https://github.com/jadenisaac2005/exptrail/tree/HEAD/examples/digits/runs/20260924-055909_sgd-lr0.1/) | 0.1 | 0.0 | 0.9611 | [`42124f2`](https://github.com/jadenisaac2005/exptrail/commit/42124f208a0411b96a60bc720978ac602389a53e) |
|
|
195
|
+
| [momentum-lr0.05](https://github.com/jadenisaac2005/exptrail/tree/HEAD/examples/digits/runs/20260924-055910_momentum-lr0.05/) | 0.05 | 0.9 | 0.9694 | [`42124f2`](https://github.com/jadenisaac2005/exptrail/commit/42124f208a0411b96a60bc720978ac602389a53e) |
|
|
196
|
+
<!-- results:end -->
|
|
197
|
+
|
|
198
|
+
## License
|
|
199
|
+
|
|
200
|
+
MIT
|
exptrail-0.1.0/README.md
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# exptrail
|
|
2
|
+
|
|
3
|
+
Local, framework-agnostic experiment logging so that **every number in your README can be traced to a saved run and re-checked**.
|
|
4
|
+
|
|
5
|
+
README results tables drift: configs get overwritten, loss curves aren't saved, and nobody remembers which commit produced 0.9807. `exptrail` writes each run to its own folder (config, per-step metrics, final numbers, git commit + diff, environment), generates the README table *from those folders*, and `exptrail verify` fails CI if the table and the runs disagree.
|
|
6
|
+
|
|
7
|
+
- Standard library only (Python 3.10+). `matplotlib` optional, for `plot`.
|
|
8
|
+
- Works with NumPy-from-scratch, PyTorch, sklearn, anything: you just pass numbers.
|
|
9
|
+
- Works without git (e.g. Colab): recorded as `no-git` with a warning, never an error.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install exptrail # or: pip install "exptrail[plot]"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Log a run
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from exptrail import Run
|
|
21
|
+
|
|
22
|
+
with Run(name="momentum-lr0.01", config={"lr": 0.01, "momentum": 0.9},
|
|
23
|
+
tags=["digits"], seeds={"numpy": 0}) as run:
|
|
24
|
+
for epoch in range(30):
|
|
25
|
+
...
|
|
26
|
+
run.log(step=epoch, train_loss=loss, val_acc=acc) # appended + flushed immediately
|
|
27
|
+
run.summary(test_acc=0.9807) # the numbers you report
|
|
28
|
+
run.save_artifact("model.npz") # copied into the run folder
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or as a decorator. The function's arguments become the config, a `run` parameter receives the active run, and a returned dict becomes the summary:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from exptrail import track
|
|
35
|
+
|
|
36
|
+
@track(name="mlp")
|
|
37
|
+
def train(lr=0.01, epochs=30, run=None):
|
|
38
|
+
...
|
|
39
|
+
return {"test_acc": acc}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
If one `with` block doesn't fit your script (a notebook, a training loop spread over several functions), start and finish the run yourself:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
run = Run(name="momentum-lr0.01", config=cfg).start()
|
|
46
|
+
... # train, run.log(...), run.summary(...)
|
|
47
|
+
run.finish() # or run.finish("failed"); calling it twice is harmless
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`with Run(...)`, `@track` and `start()`/`finish()` share one code path. Wrap **the whole job**, data loading and evaluation included: `duration_s` only covers the run's own span, and anything that crashes outside it isn't recorded.
|
|
51
|
+
|
|
52
|
+
Each run writes `runs/<UTC timestamp>_<name>/` (override with `root=` or `$EXPTRAIL_ROOT`):
|
|
53
|
+
|
|
54
|
+
| file | contents |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `config.json` | the config you passed |
|
|
57
|
+
| `metrics.csv` | one row per `log()` call: `step`, then your metrics |
|
|
58
|
+
| `summary.json` | final numbers, written on every `summary()` call |
|
|
59
|
+
| `meta.json` | status (see [Run status](#run-status)), git commit/branch/remote/dirty flag (and which files set it), Python + numpy/torch/scikit-learn versions, hostname, argv, cwd, seeds, start/end time, and a `liveness` record (hostname, pid, process start time, boot ID, heartbeat interval) |
|
|
60
|
+
| `heartbeat` | touched every 30 s while the run is alive; removed when it finishes |
|
|
61
|
+
| `git_diff.patch` | uncommitted changes (tracked edits plus new untracked code files), only if there were any |
|
|
62
|
+
| `traceback.txt` | only if the run raised |
|
|
63
|
+
| `artifacts/` | anything passed to `save_artifact()` |
|
|
64
|
+
|
|
65
|
+
Paths in `meta.json` (`cwd`, `python_executable`, `argv`, `git.root`) have your home directory replaced with `~`, so committed runs don't leak your username. Pass `Run(..., redact_paths=False)` to keep them verbatim. Credentials in the git remote URL are always stripped.
|
|
66
|
+
|
|
67
|
+
A dirty working tree triggers a loud `DirtyTreeWarning`: that run's numbers can't be reproduced from a commit, so commit first if you plan to report them.
|
|
68
|
+
|
|
69
|
+
### What counts as dirty
|
|
70
|
+
|
|
71
|
+
- **Modified tracked files** make a run dirty, unless they match `dirty_ignore`.
|
|
72
|
+
- **Untracked files** make a run dirty only if they look like code, i.e. match `untracked_code`. The default patterns are `*.py`, `*.ipynb`, `*.pyx`, `*.yaml`, `*.yml` and `*.toml`. Other untracked files (CSVs, logs, model outputs, the `runs/` folder) are ignored entirely. Files your `.gitignore` excludes never count, and neither does anything inside a virtualenv (a folder containing `pyvenv.cfg`).
|
|
73
|
+
- **`dirty_ignore`** lists glob patterns for changes that shouldn't mark a run dirty, such as a results log you edit by hand. Those changes don't set the flag, don't warn, and don't fail `verify --strict`, but they are still written to `git_diff.patch`, so the record stays complete.
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
Run("momentum-lr0.01", dirty_ignore=["*.md", "submissions/**"],
|
|
77
|
+
untracked_code=["*.py", "*.yaml"]) # replaces the default code patterns
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
A pattern without `/` (`*.md`) matches that file name in any directory. A pattern with `/` (`submissions/**`) matches the path from the repo root, and a trailing `/` (`submissions/`) means everything under that folder.
|
|
81
|
+
|
|
82
|
+
`git_diff.patch` includes untracked code files as new-file diffs, so `git apply git_diff.patch` on the recorded commit rebuilds the tree the run used. Your git index is never touched: the files are added to a temporary copy of it. Untracked files over 1 MB are recorded by path, size and sha256 in `meta.json` (and in the patch header, when there is a patch) instead of being inlined. If such a file is the only change, no `git_diff.patch` is written, since there would be nothing for `git apply` to apply.
|
|
83
|
+
|
|
84
|
+
`meta.json` records what was found under `git`: `dirty_files` (`tracked` and `untracked` lists of the files that made the run dirty), `ignored_files` (changed files that matched `dirty_ignore`), `changed_files` (every changed file that was recorded, in the patch or by hash), `untracked_too_large` (hashed files, if any), and the `dirty_ignore`/`untracked_code` patterns that were in effect.
|
|
85
|
+
|
|
86
|
+
If listing untracked files fails (it times out after 60 s, e.g. on a huge un-ignored data folder or a repo on a network drive), the commit, branch and tracked changes are still recorded, `untracked_scan` says why the scan failed, and the run is treated as dirty because it can't be shown to be clean.
|
|
87
|
+
|
|
88
|
+
On Python 3.11+ you can set the same keys once for the whole repo in `pyproject.toml`. Keyword arguments override it:
|
|
89
|
+
|
|
90
|
+
```toml
|
|
91
|
+
[tool.exptrail]
|
|
92
|
+
dirty_ignore = ["*.md", "submissions/**"]
|
|
93
|
+
untracked_code = ["*.py", "*.ipynb", "*.yaml"]
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Python 3.10 has no built-in TOML parser and exptrail has no dependencies, so on 3.10 a `[tool.exptrail]` section is ignored with a one-time `ConfigWarning`. Pass the keyword arguments instead.
|
|
97
|
+
|
|
98
|
+
## Run status
|
|
99
|
+
|
|
100
|
+
| status | meaning |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `finished` | `finish()` was called, or the `with` block / `@track` function returned normally |
|
|
103
|
+
| `failed` | an exception escaped the run; the traceback is in `traceback.txt` |
|
|
104
|
+
| `interrupted` | Ctrl-C, SIGTERM, or the interpreter exited while the run was started but not finished (`status_reason` says which) |
|
|
105
|
+
| `running` | still going, as far as the evidence shows |
|
|
106
|
+
| `dead` | certainly gone: the process was killed (SIGKILL, the OOM killer), the pid now belongs to another process, or the machine has rebooted |
|
|
107
|
+
| `stale` | probably gone: no heartbeat for a while |
|
|
108
|
+
|
|
109
|
+
The usual ways ML jobs die (SIGKILL, the out-of-memory killer, a Colab disconnect, a laptop losing power) run no cleanup code, so nothing can write "died" into `meta.json`. Instead, a run records who it is when it starts (hostname, boot ID, pid, process start time) and a background thread touches its `heartbeat` file every 30 seconds, whether or not you call `log()`. `ls`, `show`, `compare` and `verify` turn a stored `running` into one of the statuses above when they read the run, and they never write to the run folder:
|
|
110
|
+
|
|
111
|
+
- **On the machine the run started on**, the answer is certain: the process is alive (`running`) or it isn't (`dead (pid gone)`, `dead (pid reused)`, `dead (machine rebooted)`).
|
|
112
|
+
- **From another machine** (a run folder on a shared drive, or synced back from a cluster), or where the process can't be inspected (Windows, runs from older exptrail versions), only the heartbeat's age is available. If it is older than 3 heartbeat intervals the run shows as `stale (last heartbeat 14m ago)`. That is only *probable*: the other machine may be alive but cut off from the shared drive, or its clock may disagree with yours.
|
|
113
|
+
|
|
114
|
+
Heartbeat age is the `heartbeat` file's modification time (mtime). Copying a run folder resets it: `git clone` and `git checkout`, `cp` without `-p`, and cloud sync (Google Drive, Dropbox) all give the file a fresh mtime. So a crashed run folder that was copied or committed and then read on another machine can show as `running (on gpu-1, last heartbeat 5s ago)` for a few minutes, until the copied heartbeat goes stale. `verify` still fails such a run, because its status isn't `finished`.
|
|
115
|
+
|
|
116
|
+
Change the heartbeat interval with `Run(..., heartbeat_s=60)` or `$EXPTRAIL_HEARTBEAT_S`, and the staleness threshold with `$EXPTRAIL_STALE_S` (seconds). On SIGTERM a run is marked `interrupted` before the process exits as it normally would; exptrail only installs its handler when there isn't one already, and restores the previous handler at `finish()`.
|
|
117
|
+
|
|
118
|
+
To make a `dead` or `stale` status permanent, record what happened by hand:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
exptrail mark momentum-lr0.01 interrupted # or failed / finished; notes that it was marked by hand, and when
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`mark` refuses a run that still shows as `running` unless you pass `--force`.
|
|
125
|
+
|
|
126
|
+
## CLI
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
exptrail ls # name, status, key metrics, commit (* = dirty)
|
|
130
|
+
exptrail show momentum-lr0.01 # config + summary + meta
|
|
131
|
+
exptrail compare sgd-lr0.1 momentum-lr0.01 # config diff + metric table
|
|
132
|
+
exptrail plot sgd-lr0.1 momentum-lr0.01 --metric val_acc -o val_acc.png
|
|
133
|
+
exptrail table --metric test_acc --runs '*digits*' --readme README.md
|
|
134
|
+
exptrail verify README.md # non-zero exit on any mismatch
|
|
135
|
+
exptrail mark momentum-lr0.01 interrupted # make a dead/stale status permanent
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Runs can be referred to by folder name, path, run name (latest wins) or a unique substring. `--root DIR` points any command at another runs directory.
|
|
139
|
+
|
|
140
|
+
**`table`** writes a markdown table between `<!-- results:start -->` and `<!-- results:end -->` marker lines (appended if the markers aren't there yet). Markers only count when each is on a line of its own, and there must be exactly one pair. Each row links to the run folder and commit. Re-running it replaces the block; it never duplicates it. Add columns with repeatable `--metric` and `--config` flags. Floats are shown to `--precision` decimals (default 4). Only finished runs are included unless you pass `--include-failed`. Links to run folders are relative to the README; pass `--absolute-links` to link them on GitHub/GitLab instead (needed for a README shown on PyPI, which can't resolve relative links).
|
|
141
|
+
|
|
142
|
+
**`verify`** re-reads every run linked from the block. It fails if a displayed metric doesn't match `summary.json` at the displayed precision, if a config value doesn't exactly match `config.json`, if the commit differs from `meta.json`, if a linked run folder is missing, or if a linked run isn't `finished` (running, dead, stale, failed or interrupted; the message shows the status). Runs that were dirty or git-less produce warnings, and `--strict` turns them into failures. Put it in CI:
|
|
143
|
+
|
|
144
|
+
```yaml
|
|
145
|
+
- run: pip install exptrail && exptrail verify README.md
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
For links in the table to work on GitHub, commit the run folders you report.
|
|
149
|
+
|
|
150
|
+
## Example
|
|
151
|
+
|
|
152
|
+
[`examples/digits`](https://github.com/jadenisaac2005/exptrail/tree/HEAD/examples/digits) trains a NumPy MLP on sklearn's digits with 3 optimiser configs and regenerates its README table. Its results, generated with `exptrail --root examples/digits/runs table --metric test_acc --config lr --config momentum --absolute-links --readme README.md`:
|
|
153
|
+
|
|
154
|
+
<!-- results:start -->
|
|
155
|
+
<!-- Generated by exptrail. Do not edit by hand; `exptrail verify` checks these numbers. -->
|
|
156
|
+
<!-- command: exptrail --root examples/digits/runs table --metric test_acc --config lr --config momentum --absolute-links --readme README.md -->
|
|
157
|
+
| run | lr | momentum | test_acc | commit |
|
|
158
|
+
|---|---|---|---|---|
|
|
159
|
+
| [momentum-lr0.01](https://github.com/jadenisaac2005/exptrail/tree/HEAD/examples/digits/runs/20260924-055909_momentum-lr0.01/) | 0.01 | 0.9 | 0.9611 | [`42124f2`](https://github.com/jadenisaac2005/exptrail/commit/42124f208a0411b96a60bc720978ac602389a53e) |
|
|
160
|
+
| [sgd-lr0.1](https://github.com/jadenisaac2005/exptrail/tree/HEAD/examples/digits/runs/20260924-055909_sgd-lr0.1/) | 0.1 | 0.0 | 0.9611 | [`42124f2`](https://github.com/jadenisaac2005/exptrail/commit/42124f208a0411b96a60bc720978ac602389a53e) |
|
|
161
|
+
| [momentum-lr0.05](https://github.com/jadenisaac2005/exptrail/tree/HEAD/examples/digits/runs/20260924-055910_momentum-lr0.05/) | 0.05 | 0.9 | 0.9694 | [`42124f2`](https://github.com/jadenisaac2005/exptrail/commit/42124f208a0411b96a60bc720978ac602389a53e) |
|
|
162
|
+
<!-- results:end -->
|
|
163
|
+
|
|
164
|
+
## License
|
|
165
|
+
|
|
166
|
+
MIT
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "exptrail"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Local, framework-agnostic ML experiment logging that keeps README numbers verifiable."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "Jaden Isaac" }]
|
|
14
|
+
keywords = ["machine-learning", "experiment-tracking", "reproducibility", "logging", "mlops"]
|
|
15
|
+
dependencies = []
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Intended Audience :: Science/Research",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
29
|
+
"Topic :: System :: Logging",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/jadenisaac2005/exptrail"
|
|
34
|
+
Source = "https://github.com/jadenisaac2005/exptrail"
|
|
35
|
+
Issues = "https://github.com/jadenisaac2005/exptrail/issues"
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
plot = ["matplotlib"]
|
|
39
|
+
dev = ["pytest", "pytest-cov", "matplotlib"]
|
|
40
|
+
|
|
41
|
+
[project.scripts]
|
|
42
|
+
exptrail = "exptrail.cli:main"
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.packages.find]
|
|
45
|
+
where = ["src"]
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
testpaths = ["tests"]
|
exptrail-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""exptrail: local experiment logging that keeps README numbers verifiable."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .run import ConfigWarning, DirtyTreeWarning, NoGitWarning, Run, track # noqa: E402
|
|
6
|
+
|
|
7
|
+
__all__ = ["Run", "track", "DirtyTreeWarning", "NoGitWarning", "ConfigWarning", "__version__"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Point warnings at the user's line, however many exptrail frames are in between."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
_PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__)) + os.sep
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _in_package(filename: str) -> bool:
|
|
12
|
+
return os.path.abspath(filename).startswith(_PACKAGE_DIR)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def user_stacklevel() -> int:
|
|
16
|
+
"""``stacklevel`` for a ``warnings.warn`` made by the caller of this function.
|
|
17
|
+
|
|
18
|
+
Walks up to the first frame outside the exptrail package, so the warning
|
|
19
|
+
names the user's ``with Run(...)``, ``run.start()`` or ``@track``-decorated
|
|
20
|
+
call, whichever path led here.
|
|
21
|
+
"""
|
|
22
|
+
frame = sys._getframe(1) # the function that is about to call warnings.warn
|
|
23
|
+
level = 1
|
|
24
|
+
while frame is not None and _in_package(frame.f_code.co_filename):
|
|
25
|
+
frame = frame.f_back
|
|
26
|
+
level += 1
|
|
27
|
+
return level
|