expr_tracker 0.1.5__tar.gz → 0.1.7__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.
- expr_tracker-0.1.7/.github/workflows/release.yaml +53 -0
- {expr_tracker-0.1.5 → expr_tracker-0.1.7}/PKG-INFO +29 -1
- expr_tracker-0.1.7/README.md +55 -0
- expr_tracker-0.1.7/src/expr_tracker/jsonl.py +209 -0
- {expr_tracker-0.1.5 → expr_tracker-0.1.7}/src/expr_tracker/tracker.py +16 -9
- expr_tracker-0.1.7/tests/test_jsonl_buffer.py +113 -0
- expr_tracker-0.1.5/README.md +0 -27
- expr_tracker-0.1.5/src/expr_tracker/jsonl.py +0 -59
- {expr_tracker-0.1.5 → expr_tracker-0.1.7}/.gitignore +0 -0
- {expr_tracker-0.1.5 → expr_tracker-0.1.7}/pyproject.toml +0 -0
- {expr_tracker-0.1.5 → expr_tracker-0.1.7}/src/expr_tracker/__init__.py +0 -0
- {expr_tracker-0.1.5 → expr_tracker-0.1.7}/src/expr_tracker/_compat.py +0 -0
- {expr_tracker-0.1.5 → expr_tracker-0.1.7}/src/expr_tracker/alert.py +0 -0
- {expr_tracker-0.1.5 → expr_tracker-0.1.7}/src/expr_tracker/cli.py +0 -0
- {expr_tracker-0.1.5 → expr_tracker-0.1.7}/src/expr_tracker/encoders.py +0 -0
- {expr_tracker-0.1.5 → expr_tracker-0.1.7}/src/expr_tracker/types.py +0 -0
- {expr_tracker-0.1.5 → expr_tracker-0.1.7}/tests/test_tracker.py +0 -0
- {expr_tracker-0.1.5 → expr_tracker-0.1.7}/uv.lock +0 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
release:
|
|
14
|
+
name: Build and publish to PyPI
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
permissions:
|
|
17
|
+
# required for PyPI trusted publishing (OIDC)
|
|
18
|
+
id-token: write
|
|
19
|
+
# required to create the GitHub release
|
|
20
|
+
contents: write
|
|
21
|
+
steps:
|
|
22
|
+
- name: Checkout
|
|
23
|
+
uses: actions/checkout@v5
|
|
24
|
+
with:
|
|
25
|
+
# uv-dynamic-versioning derives the version from git tags
|
|
26
|
+
fetch-depth: 0
|
|
27
|
+
|
|
28
|
+
- name: Install uv
|
|
29
|
+
uses: astral-sh/setup-uv@v6
|
|
30
|
+
with:
|
|
31
|
+
enable-cache: true
|
|
32
|
+
|
|
33
|
+
- name: Build sdist and wheel
|
|
34
|
+
run: |
|
|
35
|
+
uv build
|
|
36
|
+
ls -l dist/
|
|
37
|
+
|
|
38
|
+
- name: Check distribution metadata
|
|
39
|
+
run: uvx twine check dist/*
|
|
40
|
+
|
|
41
|
+
- name: Publish to PyPI
|
|
42
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
43
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
44
|
+
|
|
45
|
+
- name: Create GitHub release
|
|
46
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
47
|
+
env:
|
|
48
|
+
GH_TOKEN: ${{ github.token }}
|
|
49
|
+
run: |
|
|
50
|
+
gh release create "${GITHUB_REF_NAME}" dist/* \
|
|
51
|
+
--repo "${GITHUB_REPOSITORY}" \
|
|
52
|
+
--title "${GITHUB_REF_NAME}" \
|
|
53
|
+
--generate-notes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: expr_tracker
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.7
|
|
4
4
|
Summary: Add your description here
|
|
5
5
|
Author-email: HSPK <whxway@whu.edu.cn>
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -36,4 +36,32 @@ et.init(project="my_project", name="my_experiment", backends=["wandb", "jsonl"])
|
|
|
36
36
|
et.log({"accuracy": 0.95, "loss": 0.05})
|
|
37
37
|
et.alert("Experiment completed!", text="Your experiment has finished successfully.", subtitle="Experiment Status")
|
|
38
38
|
et.finish()
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### JSONL buffering
|
|
42
|
+
|
|
43
|
+
The `jsonl` backend adapts its buffering to how often you call `log()`, so that
|
|
44
|
+
high-frequency logging doesn't hammer the disk (helpful on network mounts such as
|
|
45
|
+
BlobFuse) while low-frequency logging still lands on disk immediately:
|
|
46
|
+
|
|
47
|
+
- If the gap since the previous `log()` call is `>= buffer_interval` (default `1.0s`),
|
|
48
|
+
the call is considered low-frequency and is written straight through.
|
|
49
|
+
- Otherwise records are batched in memory and flushed once `buffer_size` (default `50`)
|
|
50
|
+
records accumulate.
|
|
51
|
+
- A background timer flushes records that have been buffered for more than
|
|
52
|
+
`max_buffer_seconds` (default `5.0s`), so a burst that suddenly stops is never stranded
|
|
53
|
+
in memory. `finish()` and the `atexit` hook flush any remainder.
|
|
54
|
+
|
|
55
|
+
Set `buffer_interval=None` to disable the frequency check, or `max_buffer_seconds=None`
|
|
56
|
+
to disable the background timer. Tune them via `backend_kwargs`:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
et.init(
|
|
60
|
+
project="my_project",
|
|
61
|
+
name="my_experiment",
|
|
62
|
+
backends=["jsonl"],
|
|
63
|
+
backend_kwargs={
|
|
64
|
+
"jsonl": {"buffer_size": 200, "buffer_interval": 0.5, "max_buffer_seconds": 10},
|
|
65
|
+
},
|
|
66
|
+
)
|
|
39
67
|
```
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Experiment Tracker
|
|
2
|
+
|
|
3
|
+
A simple experiment tracker that supports `wandb`, `trackio` and local `jsonl` storage. Features include:
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
1. **Multi-Backend Support**: Compatible with `wandb`, `trackio`, and local `jsonl` storage.
|
|
7
|
+
2. **Alert System**: Send alerts via Lark, with email and other platforms to be added.
|
|
8
|
+
3. **Resume Functionality**: Allows resuming experiments using project and name as unique identifiers.
|
|
9
|
+
4. **Simple API**: Provides a straightforward API similar to `wandb`, making it easy to integrate into existing workflows.
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
Add dependency to your project:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
uv add expr_tracker
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Simple usage example:
|
|
20
|
+
```python
|
|
21
|
+
import expr_tracker as et
|
|
22
|
+
|
|
23
|
+
et.init(project="my_project", name="my_experiment", backends=["wandb", "jsonl"])
|
|
24
|
+
et.log({"accuracy": 0.95, "loss": 0.05})
|
|
25
|
+
et.alert("Experiment completed!", text="Your experiment has finished successfully.", subtitle="Experiment Status")
|
|
26
|
+
et.finish()
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### JSONL buffering
|
|
30
|
+
|
|
31
|
+
The `jsonl` backend adapts its buffering to how often you call `log()`, so that
|
|
32
|
+
high-frequency logging doesn't hammer the disk (helpful on network mounts such as
|
|
33
|
+
BlobFuse) while low-frequency logging still lands on disk immediately:
|
|
34
|
+
|
|
35
|
+
- If the gap since the previous `log()` call is `>= buffer_interval` (default `1.0s`),
|
|
36
|
+
the call is considered low-frequency and is written straight through.
|
|
37
|
+
- Otherwise records are batched in memory and flushed once `buffer_size` (default `50`)
|
|
38
|
+
records accumulate.
|
|
39
|
+
- A background timer flushes records that have been buffered for more than
|
|
40
|
+
`max_buffer_seconds` (default `5.0s`), so a burst that suddenly stops is never stranded
|
|
41
|
+
in memory. `finish()` and the `atexit` hook flush any remainder.
|
|
42
|
+
|
|
43
|
+
Set `buffer_interval=None` to disable the frequency check, or `max_buffer_seconds=None`
|
|
44
|
+
to disable the background timer. Tune them via `backend_kwargs`:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
et.init(
|
|
48
|
+
project="my_project",
|
|
49
|
+
name="my_experiment",
|
|
50
|
+
backends=["jsonl"],
|
|
51
|
+
backend_kwargs={
|
|
52
|
+
"jsonl": {"buffer_size": 200, "buffer_interval": 0.5, "max_buffer_seconds": 10},
|
|
53
|
+
},
|
|
54
|
+
)
|
|
55
|
+
```
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import atexit
|
|
2
|
+
import json
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import jsonlines
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
from expr_tracker.encoders import jsonable_encoder
|
|
11
|
+
|
|
12
|
+
DEFAULT_BUFFER_SIZE = 50
|
|
13
|
+
DEFAULT_BUFFER_INTERVAL = 1.0
|
|
14
|
+
DEFAULT_MAX_BUFFER_SECONDS = 5.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class JsonlTracker:
|
|
18
|
+
def __init__(self):
|
|
19
|
+
self.buffer = []
|
|
20
|
+
self.buffer_size = DEFAULT_BUFFER_SIZE
|
|
21
|
+
self.buffer_interval = DEFAULT_BUFFER_INTERVAL
|
|
22
|
+
self.max_buffer_seconds = DEFAULT_MAX_BUFFER_SECONDS
|
|
23
|
+
self.log_fp = None
|
|
24
|
+
self._lock = threading.RLock()
|
|
25
|
+
self._last_log_time = None
|
|
26
|
+
self._first_buffered_time = None
|
|
27
|
+
self._flush_timer = None
|
|
28
|
+
|
|
29
|
+
def init(
|
|
30
|
+
self,
|
|
31
|
+
project: str,
|
|
32
|
+
name: str | None = None,
|
|
33
|
+
config: dict | None = None,
|
|
34
|
+
dir: str | None = None,
|
|
35
|
+
print_to_screen: bool = False,
|
|
36
|
+
print_handle=print,
|
|
37
|
+
buffer_size: int = DEFAULT_BUFFER_SIZE,
|
|
38
|
+
buffer_interval: float | None = DEFAULT_BUFFER_INTERVAL,
|
|
39
|
+
max_buffer_seconds: float | None = DEFAULT_MAX_BUFFER_SECONDS,
|
|
40
|
+
**kwargs,
|
|
41
|
+
):
|
|
42
|
+
"""初始化 jsonl backend。
|
|
43
|
+
|
|
44
|
+
缓冲策略(按 log 频率自适应):
|
|
45
|
+
- ``buffer_size``: buffer 中记录数达到该值立即写盘。
|
|
46
|
+
- ``buffer_interval``: 相邻两次 ``log()`` 的间隔 >= 该值时认为不是高频写入,
|
|
47
|
+
直接写盘(低延迟);小于该值才认为是高频写入,先攒在内存里。
|
|
48
|
+
设为 ``None`` 表示关闭该判断(只按 buffer_size 攒批)。
|
|
49
|
+
- ``max_buffer_seconds``: 记录在 buffer 中的最长停留时间,超时后由后台定时器
|
|
50
|
+
强制写盘,避免高频写入突然停止时数据长期滞留内存。设为 ``None`` 关闭。
|
|
51
|
+
"""
|
|
52
|
+
self.project = project
|
|
53
|
+
self.name = name
|
|
54
|
+
if dir is None:
|
|
55
|
+
dir = "./tracker/jsonl"
|
|
56
|
+
self.log_dir = Path(dir) / self.project / self.name
|
|
57
|
+
self.config_fp = self.log_dir / "config.json"
|
|
58
|
+
self.log_fp = self.log_dir / "metrics.jsonl"
|
|
59
|
+
|
|
60
|
+
# 初始化 Buffer 配置
|
|
61
|
+
self._cancel_timer()
|
|
62
|
+
with self._lock:
|
|
63
|
+
self.buffer = []
|
|
64
|
+
self.buffer_size = max(1, int(buffer_size))
|
|
65
|
+
self.buffer_interval = (
|
|
66
|
+
None if buffer_interval is None else float(buffer_interval)
|
|
67
|
+
)
|
|
68
|
+
self.max_buffer_seconds = (
|
|
69
|
+
None if max_buffer_seconds is None else float(max_buffer_seconds)
|
|
70
|
+
)
|
|
71
|
+
self._last_log_time = None
|
|
72
|
+
self._first_buffered_time = None
|
|
73
|
+
|
|
74
|
+
self.log_dir.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
|
|
76
|
+
if self.config_fp.exists():
|
|
77
|
+
logger.warning(
|
|
78
|
+
f"Config file {self.config_fp} already exists. It will be overwritten."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if config is not None:
|
|
82
|
+
# Config 通常只写一次,直接写入即可
|
|
83
|
+
with open(self.config_fp, "w") as f:
|
|
84
|
+
json.dump(jsonable_encoder(config), f, indent=4)
|
|
85
|
+
|
|
86
|
+
self.print_to_screen = print_to_screen
|
|
87
|
+
self.print_handle = print_handle
|
|
88
|
+
|
|
89
|
+
# 优化:流式计算行数,避免一次性加载大文件到内存 (对 BlobFuse 友好)
|
|
90
|
+
self.current_step = 0
|
|
91
|
+
if self.log_fp.exists():
|
|
92
|
+
try:
|
|
93
|
+
with open(self.log_fp, "rb") as f:
|
|
94
|
+
self.current_step = sum(1 for _ in f)
|
|
95
|
+
except Exception as e:
|
|
96
|
+
logger.warning(f"Could not count existing lines in {self.log_fp}: {e}")
|
|
97
|
+
|
|
98
|
+
# 注册退出钩子:确保程序意外终止时也能写入剩余数据
|
|
99
|
+
atexit.register(self.flush)
|
|
100
|
+
|
|
101
|
+
def log(self, metrics: dict, step: int | None = None):
|
|
102
|
+
now = time.monotonic()
|
|
103
|
+
|
|
104
|
+
with self._lock:
|
|
105
|
+
if step is not None:
|
|
106
|
+
self.current_step = step
|
|
107
|
+
|
|
108
|
+
record = {"_step": self.current_step, **metrics}
|
|
109
|
+
|
|
110
|
+
# 1. 写入内存 Buffer
|
|
111
|
+
self.buffer.append(record)
|
|
112
|
+
if self._first_buffered_time is None:
|
|
113
|
+
self._first_buffered_time = now
|
|
114
|
+
|
|
115
|
+
# 2. 根据「距离上一次 log 的时间间隔」判断是否为高频写入
|
|
116
|
+
interval = None if self._last_log_time is None else now - self._last_log_time
|
|
117
|
+
self._last_log_time = now
|
|
118
|
+
|
|
119
|
+
should_flush = self._should_flush(now, interval)
|
|
120
|
+
self.current_step += 1
|
|
121
|
+
|
|
122
|
+
# 3. 屏幕打印(放在锁外,避免 print handle 阻塞其他线程)
|
|
123
|
+
if self.print_to_screen:
|
|
124
|
+
self.print_handle(f"{record}")
|
|
125
|
+
|
|
126
|
+
# 4. 立即写盘,或安排一次超时写盘
|
|
127
|
+
if should_flush:
|
|
128
|
+
self.flush()
|
|
129
|
+
else:
|
|
130
|
+
self._schedule_timer()
|
|
131
|
+
|
|
132
|
+
def _should_flush(self, now: float, interval: float | None) -> bool:
|
|
133
|
+
"""判断当前是否应该立即写盘(需在持锁状态下调用)"""
|
|
134
|
+
# Buffer 已满
|
|
135
|
+
if len(self.buffer) >= self.buffer_size:
|
|
136
|
+
return True
|
|
137
|
+
# 首次 log:直接落盘,尽快产生文件内容
|
|
138
|
+
if interval is None:
|
|
139
|
+
return True
|
|
140
|
+
# 低频写入:距离上次 log 已经过去足够久,没必要继续攒批
|
|
141
|
+
if self.buffer_interval is not None and interval >= self.buffer_interval:
|
|
142
|
+
return True
|
|
143
|
+
# 高频写入,但最早的记录已在内存中停留过久
|
|
144
|
+
return (
|
|
145
|
+
self.max_buffer_seconds is not None
|
|
146
|
+
and self._first_buffered_time is not None
|
|
147
|
+
and now - self._first_buffered_time >= self.max_buffer_seconds
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def _schedule_timer(self):
|
|
151
|
+
"""为 buffer 中最早的记录安排一次超时写盘"""
|
|
152
|
+
if self.max_buffer_seconds is None:
|
|
153
|
+
return
|
|
154
|
+
with self._lock:
|
|
155
|
+
if self._flush_timer is not None or not self.buffer:
|
|
156
|
+
return
|
|
157
|
+
now = time.monotonic()
|
|
158
|
+
elapsed = now - (self._first_buffered_time or now)
|
|
159
|
+
delay = max(0.0, self.max_buffer_seconds - elapsed)
|
|
160
|
+
timer = threading.Timer(delay, self._on_timer)
|
|
161
|
+
timer.daemon = True
|
|
162
|
+
self._flush_timer = timer
|
|
163
|
+
timer.start()
|
|
164
|
+
|
|
165
|
+
def _on_timer(self):
|
|
166
|
+
with self._lock:
|
|
167
|
+
self._flush_timer = None
|
|
168
|
+
self.flush()
|
|
169
|
+
|
|
170
|
+
def _cancel_timer(self):
|
|
171
|
+
with self._lock:
|
|
172
|
+
timer = self._flush_timer
|
|
173
|
+
self._flush_timer = None
|
|
174
|
+
if timer is not None:
|
|
175
|
+
timer.cancel()
|
|
176
|
+
|
|
177
|
+
def flush(self):
|
|
178
|
+
"""强制将内存中的 Buffer 写入磁盘"""
|
|
179
|
+
self._cancel_timer()
|
|
180
|
+
|
|
181
|
+
with self._lock:
|
|
182
|
+
self._first_buffered_time = None
|
|
183
|
+
if not self.buffer:
|
|
184
|
+
return
|
|
185
|
+
records, self.buffer = self.buffer, []
|
|
186
|
+
log_fp = self.log_fp
|
|
187
|
+
|
|
188
|
+
# 确保目录存在 (防止运行时目录被删)
|
|
189
|
+
if log_fp and not log_fp.parent.exists():
|
|
190
|
+
log_fp.parent.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
# 批量追加写入
|
|
194
|
+
with jsonlines.open(log_fp, mode="a") as writer:
|
|
195
|
+
writer.write_all(records)
|
|
196
|
+
except Exception as e:
|
|
197
|
+
logger.error(f"Failed to flush metrics to {log_fp}: {e}")
|
|
198
|
+
# 写入失败:放回 buffer 头部,等待下次 flush 重试
|
|
199
|
+
with self._lock:
|
|
200
|
+
self.buffer[:0] = records
|
|
201
|
+
self._first_buffered_time = time.monotonic()
|
|
202
|
+
self._schedule_timer()
|
|
203
|
+
|
|
204
|
+
def finish(self):
|
|
205
|
+
"""结束时显式调用"""
|
|
206
|
+
self._cancel_timer()
|
|
207
|
+
self.flush()
|
|
208
|
+
# 如果手动调用了 finish,取消 atexit 注册,防止重复调用
|
|
209
|
+
atexit.unregister(self.flush)
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
import os
|
|
2
2
|
from contextvars import ContextVar
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
3
5
|
from loguru import logger
|
|
4
|
-
import os
|
|
5
6
|
|
|
6
7
|
_tracker: ContextVar["Tracker | None"] = ContextVar("tracker", default=None)
|
|
7
8
|
|
|
@@ -134,16 +135,22 @@ def log(metrics: dict, step: int | None = None):
|
|
|
134
135
|
tracker = _tracker.get()
|
|
135
136
|
if tracker is None:
|
|
136
137
|
raise RuntimeError("Tracker is not initialized. Call init() first.")
|
|
137
|
-
for
|
|
138
|
-
|
|
138
|
+
for backend, t in tracker.backend.items():
|
|
139
|
+
try:
|
|
140
|
+
t.log(metrics, step=step)
|
|
141
|
+
except Exception as e:
|
|
142
|
+
logger.warning(f"Failed to log metrics to {backend}: {e}")
|
|
139
143
|
|
|
140
144
|
|
|
141
145
|
def finish():
|
|
142
146
|
tracker = _tracker.get()
|
|
143
147
|
if tracker is None:
|
|
144
148
|
raise RuntimeError("Tracker is not initialized. Call init() first.")
|
|
145
|
-
for
|
|
146
|
-
|
|
149
|
+
for backend, t in tracker.backend.items():
|
|
150
|
+
try:
|
|
151
|
+
t.finish()
|
|
152
|
+
except Exception as e:
|
|
153
|
+
logger.warning(f"Failed to finish tracker for {backend}: {e}")
|
|
147
154
|
_tracker.set(None)
|
|
148
155
|
|
|
149
156
|
|
|
@@ -152,9 +159,9 @@ def info():
|
|
|
152
159
|
if tracker is None:
|
|
153
160
|
raise RuntimeError("Tracker is not initialized. Call init() first.")
|
|
154
161
|
info = {}
|
|
155
|
-
for
|
|
156
|
-
if
|
|
162
|
+
for backend, t in tracker.backend.items():
|
|
163
|
+
if backend == "wandb":
|
|
157
164
|
info["wandb"] = {"url": t.run.url}
|
|
158
|
-
if
|
|
165
|
+
if backend == "jsonl":
|
|
159
166
|
info["jsonl"] = {"log_dir": t.log_dir.as_posix()}
|
|
160
167
|
return info
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import tempfile
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import jsonlines
|
|
7
|
+
|
|
8
|
+
from expr_tracker.jsonl import JsonlTracker
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _count_lines(fp: Path) -> int:
|
|
12
|
+
if not fp.exists():
|
|
13
|
+
return 0
|
|
14
|
+
with open(fp, "rb") as f:
|
|
15
|
+
return sum(1 for _ in f)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _new_tracker(name: str, **kwargs):
|
|
19
|
+
tracker = JsonlTracker()
|
|
20
|
+
tracker.init(project="p", name=name, dir=tempfile.mkdtemp(), **kwargs)
|
|
21
|
+
return tracker
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_low_frequency_writes_through():
|
|
25
|
+
"""间隔大于 buffer_interval 的低频 log 应该直接写盘"""
|
|
26
|
+
tracker = _new_tracker("low_freq", buffer_size=50, buffer_interval=0.2)
|
|
27
|
+
for i in range(4):
|
|
28
|
+
tracker.log({"v": i})
|
|
29
|
+
time.sleep(0.25)
|
|
30
|
+
assert _count_lines(tracker.log_fp) == 4
|
|
31
|
+
assert tracker.buffer == []
|
|
32
|
+
tracker.finish()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_high_frequency_is_buffered():
|
|
36
|
+
"""高频 log 攒批,直到 buffer_size 满才写盘"""
|
|
37
|
+
tracker = _new_tracker(
|
|
38
|
+
"high_freq", buffer_size=10, buffer_interval=0.2, max_buffer_seconds=None
|
|
39
|
+
)
|
|
40
|
+
for i in range(9):
|
|
41
|
+
tracker.log({"v": i})
|
|
42
|
+
|
|
43
|
+
# 第一条直接落盘,其余 8 条留在 buffer 中
|
|
44
|
+
assert _count_lines(tracker.log_fp) == 1
|
|
45
|
+
assert len(tracker.buffer) == 8
|
|
46
|
+
|
|
47
|
+
for i in range(2):
|
|
48
|
+
tracker.log({"v": 100 + i})
|
|
49
|
+
assert _count_lines(tracker.log_fp) == 11
|
|
50
|
+
assert tracker.buffer == []
|
|
51
|
+
tracker.finish()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_max_buffer_seconds_flushes_stale_records():
|
|
55
|
+
"""高频写入突然停止时,后台定时器应在超时后写盘"""
|
|
56
|
+
tracker = _new_tracker(
|
|
57
|
+
"timer", buffer_size=1000, buffer_interval=1.0, max_buffer_seconds=0.5
|
|
58
|
+
)
|
|
59
|
+
for i in range(5):
|
|
60
|
+
tracker.log({"v": i})
|
|
61
|
+
assert _count_lines(tracker.log_fp) == 1
|
|
62
|
+
|
|
63
|
+
time.sleep(0.9)
|
|
64
|
+
assert _count_lines(tracker.log_fp) == 5
|
|
65
|
+
assert tracker.buffer == []
|
|
66
|
+
tracker.finish()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_steps_are_continuous_across_resume():
|
|
70
|
+
tracker = _new_tracker(
|
|
71
|
+
"resume", buffer_size=3, buffer_interval=None, max_buffer_seconds=None
|
|
72
|
+
)
|
|
73
|
+
log_dir = tracker.log_dir.parent.parent
|
|
74
|
+
for i in range(6):
|
|
75
|
+
tracker.log({"v": i})
|
|
76
|
+
tracker.finish()
|
|
77
|
+
assert _count_lines(tracker.log_fp) == 6
|
|
78
|
+
|
|
79
|
+
resumed = JsonlTracker()
|
|
80
|
+
resumed.init(project="p", name="resume", dir=str(log_dir))
|
|
81
|
+
assert resumed.current_step == 6
|
|
82
|
+
resumed.log({"v": 6})
|
|
83
|
+
resumed.finish()
|
|
84
|
+
|
|
85
|
+
with jsonlines.open(resumed.log_fp) as reader:
|
|
86
|
+
assert [record["_step"] for record in reader] == list(range(7))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_concurrent_logging_does_not_lose_records():
|
|
90
|
+
tracker = _new_tracker(
|
|
91
|
+
"threads", buffer_size=25, buffer_interval=0.05, max_buffer_seconds=0.2
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def worker():
|
|
95
|
+
for i in range(200):
|
|
96
|
+
tracker.log({"v": i})
|
|
97
|
+
|
|
98
|
+
threads = [threading.Thread(target=worker) for _ in range(4)]
|
|
99
|
+
for thread in threads:
|
|
100
|
+
thread.start()
|
|
101
|
+
for thread in threads:
|
|
102
|
+
thread.join()
|
|
103
|
+
tracker.finish()
|
|
104
|
+
|
|
105
|
+
assert _count_lines(tracker.log_fp) == 800
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
for _name, _fn in sorted(globals().items()):
|
|
110
|
+
if _name.startswith("test_") and callable(_fn):
|
|
111
|
+
_fn()
|
|
112
|
+
print(f"{_name} OK")
|
|
113
|
+
print("ALL OK")
|
expr_tracker-0.1.5/README.md
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
# Experiment Tracker
|
|
2
|
-
|
|
3
|
-
A simple experiment tracker that supports `wandb`, `trackio` and local `jsonl` storage. Features include:
|
|
4
|
-
|
|
5
|
-
## Features
|
|
6
|
-
1. **Multi-Backend Support**: Compatible with `wandb`, `trackio`, and local `jsonl` storage.
|
|
7
|
-
2. **Alert System**: Send alerts via Lark, with email and other platforms to be added.
|
|
8
|
-
3. **Resume Functionality**: Allows resuming experiments using project and name as unique identifiers.
|
|
9
|
-
4. **Simple API**: Provides a straightforward API similar to `wandb`, making it easy to integrate into existing workflows.
|
|
10
|
-
|
|
11
|
-
## Usage
|
|
12
|
-
|
|
13
|
-
Add dependency to your project:
|
|
14
|
-
|
|
15
|
-
```bash
|
|
16
|
-
uv add expr_tracker
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
Simple usage example:
|
|
20
|
-
```python
|
|
21
|
-
import expr_tracker as et
|
|
22
|
-
|
|
23
|
-
et.init(project="my_project", name="my_experiment", backends=["wandb", "jsonl"])
|
|
24
|
-
et.log({"accuracy": 0.95, "loss": 0.05})
|
|
25
|
-
et.alert("Experiment completed!", text="Your experiment has finished successfully.", subtitle="Experiment Status")
|
|
26
|
-
et.finish()
|
|
27
|
-
```
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
from loguru import logger
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
from expr_tracker.encoders import jsonable_encoder
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class JsonlTracker:
|
|
7
|
-
def __init__(self):
|
|
8
|
-
pass
|
|
9
|
-
|
|
10
|
-
def init(
|
|
11
|
-
self,
|
|
12
|
-
project: str,
|
|
13
|
-
name: str | None = None,
|
|
14
|
-
config: dict | None = None,
|
|
15
|
-
dir: str | None = None,
|
|
16
|
-
print_to_screen: bool = False,
|
|
17
|
-
print_handle=print,
|
|
18
|
-
**kwargs,
|
|
19
|
-
):
|
|
20
|
-
self.project = project
|
|
21
|
-
self.name = name
|
|
22
|
-
if dir is None:
|
|
23
|
-
dir = "./tracker/jsonl"
|
|
24
|
-
self.log_dir = Path(dir) / self.project / self.name
|
|
25
|
-
self.config_fp = self.log_dir / "config.json"
|
|
26
|
-
self.log_fp = self.log_dir / "metrics.jsonl"
|
|
27
|
-
self.log_dir.mkdir(parents=True, exist_ok=True)
|
|
28
|
-
if self.config_fp.exists():
|
|
29
|
-
logger.warning(
|
|
30
|
-
f"Config file {self.config_fp} already exists. It will be overwritten."
|
|
31
|
-
)
|
|
32
|
-
if config is not None:
|
|
33
|
-
with open(self.config_fp, "w") as f:
|
|
34
|
-
import json
|
|
35
|
-
|
|
36
|
-
json.dump(jsonable_encoder(config), f, indent=4)
|
|
37
|
-
self.print_to_screen = print_to_screen
|
|
38
|
-
self.print_handle = print_handle
|
|
39
|
-
self.current_step = (
|
|
40
|
-
0 if not self.log_fp.exists() else len(self.log_fp.read_text().splitlines())
|
|
41
|
-
)
|
|
42
|
-
|
|
43
|
-
def log(self, metrics: dict, step: int | None = None):
|
|
44
|
-
import jsonlines
|
|
45
|
-
|
|
46
|
-
if not self.log_fp.parent.exists():
|
|
47
|
-
self.log_fp.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
-
|
|
49
|
-
if step is not None:
|
|
50
|
-
self.current_step = step
|
|
51
|
-
metrics = {"_step": self.current_step, **metrics}
|
|
52
|
-
with jsonlines.open(self.log_fp, mode="a") as writer:
|
|
53
|
-
writer.write(metrics)
|
|
54
|
-
self.current_step += 1
|
|
55
|
-
if self.print_to_screen:
|
|
56
|
-
self.print_handle(f"{metrics}")
|
|
57
|
-
|
|
58
|
-
def finish(self):
|
|
59
|
-
pass
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|