mtrick 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.
- mtrick/__init__.py +6 -0
- mtrick/dashboard.py +139 -0
- mtrick/py.typed +0 -0
- mtrick/static/index.html +832 -0
- mtrick/tracker.py +142 -0
- mtrick/utils.py +0 -0
- mtrick-0.1.0.dist-info/METADATA +46 -0
- mtrick-0.1.0.dist-info/RECORD +10 -0
- mtrick-0.1.0.dist-info/WHEEL +4 -0
- mtrick-0.1.0.dist-info/entry_points.txt +3 -0
mtrick/__init__.py
ADDED
mtrick/dashboard.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import socket
|
|
2
|
+
import http.server
|
|
3
|
+
import socketserver
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import argparse
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def local_network_ip() -> None:
|
|
12
|
+
# Create a dummy socket to find the interface used for external routing
|
|
13
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
14
|
+
try:
|
|
15
|
+
# We use a public IP address (Google's DNS) and port.
|
|
16
|
+
# No actual connection or packet is sent over the network.
|
|
17
|
+
s.connect(("8.8.8.8", 80))
|
|
18
|
+
local_ip = s.getsockname()[0]
|
|
19
|
+
except Exception:
|
|
20
|
+
# Fallback to loopback if no active network connection exists
|
|
21
|
+
local_ip = "127.0.0.1"
|
|
22
|
+
finally:
|
|
23
|
+
s.close()
|
|
24
|
+
print(f"Local Network IP Address: {local_ip}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DashboardServer(socketserver.TCPServer):
|
|
28
|
+
allow_reuse_address = True
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self, server_address, handler_class, tracker_dir="metrics"
|
|
32
|
+
):
|
|
33
|
+
self.tracker_dir = tracker_dir
|
|
34
|
+
|
|
35
|
+
super().__init__(server_address, handler_class)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DashboardHandler(http.server.SimpleHTTPRequestHandler):
|
|
39
|
+
def do_GET(self):
|
|
40
|
+
parsed_path = urllib.parse.urlparse(self.path)
|
|
41
|
+
assert isinstance(self.server, DashboardServer)
|
|
42
|
+
tracker_dir = self.server.tracker_dir
|
|
43
|
+
|
|
44
|
+
# API Endpoint to get all runs
|
|
45
|
+
if parsed_path.path == "/api/runs":
|
|
46
|
+
self.send_response(200)
|
|
47
|
+
self.send_header("Content-type", "application/json; charset=utf-8")
|
|
48
|
+
# Add CORS headers just in case
|
|
49
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
50
|
+
self.end_headers()
|
|
51
|
+
|
|
52
|
+
runs = []
|
|
53
|
+
if os.path.exists(tracker_dir):
|
|
54
|
+
for dir_name in sorted(os.listdir(tracker_dir), reverse=True):
|
|
55
|
+
dir_path = os.path.join(tracker_dir, dir_name)
|
|
56
|
+
if os.path.isdir(dir_path):
|
|
57
|
+
meta_path = os.path.join(dir_path, "meta.json")
|
|
58
|
+
meta: dict[str, Any] = {}
|
|
59
|
+
if os.path.exists(meta_path):
|
|
60
|
+
with open(meta_path, "r") as f:
|
|
61
|
+
meta = json.load(f)
|
|
62
|
+
else:
|
|
63
|
+
meta = {"experiment_name": dir_name, "timestamp": "Unknown"}
|
|
64
|
+
meta["folder"] = dir_name
|
|
65
|
+
try:
|
|
66
|
+
meta["files"] = os.listdir(dir_path)
|
|
67
|
+
except Exception:
|
|
68
|
+
meta["files"] = []
|
|
69
|
+
runs.append(meta)
|
|
70
|
+
|
|
71
|
+
# print(runs)
|
|
72
|
+
self.wfile.write(json.dumps(runs).encode("utf-8"))
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# Serve the dashboard index for root path
|
|
77
|
+
elif parsed_path.path == "/":
|
|
78
|
+
self.send_response(200)
|
|
79
|
+
self.send_header("Content-type", "text/html; charset=utf-8")
|
|
80
|
+
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate")
|
|
81
|
+
self.end_headers()
|
|
82
|
+
|
|
83
|
+
static_dir = os.path.join(os.path.dirname(__file__), "static")
|
|
84
|
+
index_path = os.path.join(static_dir, "index.html")
|
|
85
|
+
|
|
86
|
+
with open(index_path, "rb") as f:
|
|
87
|
+
self.wfile.write(f.read())
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
# Serve run data files (CSV / JSON) from tracker directory
|
|
91
|
+
else:
|
|
92
|
+
# only serve files that resolve inside TRACKER_DIR
|
|
93
|
+
requested = urllib.parse.unquote(parsed_path.path).lstrip("/")
|
|
94
|
+
tracker_root = os.path.realpath(tracker_dir)
|
|
95
|
+
local_path = os.path.realpath(os.path.join(tracker_dir, requested))
|
|
96
|
+
if not local_path.startswith(tracker_root + os.sep) or not os.path.isfile(
|
|
97
|
+
local_path
|
|
98
|
+
):
|
|
99
|
+
self.send_error(404, "Not Found")
|
|
100
|
+
return
|
|
101
|
+
self.send_response(200)
|
|
102
|
+
if local_path.endswith(".json") or local_path.endswith(".jsonl"):
|
|
103
|
+
content_type = "application/json; charset=utf-8"
|
|
104
|
+
else:
|
|
105
|
+
content_type = "text/plain; charset=utf-8"
|
|
106
|
+
self.send_header("Content-type", content_type)
|
|
107
|
+
self.end_headers()
|
|
108
|
+
with open(local_path, "rb") as f:
|
|
109
|
+
self.wfile.write(f.read())
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def main():
|
|
113
|
+
local_network_ip()
|
|
114
|
+
|
|
115
|
+
parser = argparse.ArgumentParser()
|
|
116
|
+
parser.add_argument("--port", type=int, default=8000, help="Port to run on")
|
|
117
|
+
parser.add_argument(
|
|
118
|
+
"--tracker-dir",
|
|
119
|
+
type=str,
|
|
120
|
+
default="metrics",
|
|
121
|
+
help="Local directory to scan for runs",
|
|
122
|
+
)
|
|
123
|
+
args = parser.parse_args()
|
|
124
|
+
|
|
125
|
+
port = args.port
|
|
126
|
+
tracker_dir = args.tracker_dir
|
|
127
|
+
|
|
128
|
+
with DashboardServer(
|
|
129
|
+
("", port), DashboardHandler, tracker_dir=tracker_dir
|
|
130
|
+
) as httpd:
|
|
131
|
+
print(f"🚀 Dashboard is live! Ctrl / Cmd Click >>> http://localhost:{port} <<<")
|
|
132
|
+
try:
|
|
133
|
+
httpd.serve_forever()
|
|
134
|
+
except KeyboardInterrupt:
|
|
135
|
+
print("\nShutting down dashboard...")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
main()
|
mtrick/py.typed
ADDED
|
File without changes
|
mtrick/static/index.html
ADDED
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="UTF-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
|
+
<title>MTRICK</title>
|
|
8
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
9
|
+
<script src="https://cdn.jsdelivr.net/npm/chartjs-chart-matrix@latest/dist/chartjs-chart-matrix.min.js"></script>
|
|
10
|
+
<style>
|
|
11
|
+
body {
|
|
12
|
+
margin: 0;
|
|
13
|
+
padding: 0;
|
|
14
|
+
font-family: monospace;
|
|
15
|
+
background-color: #111;
|
|
16
|
+
color: #eee;
|
|
17
|
+
height: 100vh;
|
|
18
|
+
display: flex;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.sidebar {
|
|
22
|
+
width: 300px;
|
|
23
|
+
background: #222;
|
|
24
|
+
border-right: 1px solid #444;
|
|
25
|
+
display: flex;
|
|
26
|
+
flex-direction: column;
|
|
27
|
+
padding: 20px;
|
|
28
|
+
overflow-y: auto;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.run-item {
|
|
32
|
+
border: 1px solid #444;
|
|
33
|
+
padding: 10px;
|
|
34
|
+
margin-bottom: 10px;
|
|
35
|
+
transition: background 0.2s;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.run-item:hover {
|
|
39
|
+
background: #333;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.run-item.active {
|
|
43
|
+
background: #444;
|
|
44
|
+
border-color: #888;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.main-content {
|
|
48
|
+
flex: 1;
|
|
49
|
+
padding: 20px;
|
|
50
|
+
overflow-y: auto;
|
|
51
|
+
display: flex;
|
|
52
|
+
flex-direction: column;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.chart-box {
|
|
56
|
+
background: #222;
|
|
57
|
+
border: 1px solid #444;
|
|
58
|
+
padding: 20px;
|
|
59
|
+
display: flex;
|
|
60
|
+
flex-direction: column;
|
|
61
|
+
min-width: 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#dynamic-charts-container {
|
|
65
|
+
display: grid;
|
|
66
|
+
grid-template-columns: repeat(auto-fit, minmax(450px, 1fr));
|
|
67
|
+
gap: 20px;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.header-row {
|
|
71
|
+
display: flex;
|
|
72
|
+
justify-content: space-between;
|
|
73
|
+
align-items: center;
|
|
74
|
+
margin-bottom: 20px;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#run-title-display {
|
|
78
|
+
margin: 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.chart-controls {
|
|
82
|
+
display: flex;
|
|
83
|
+
gap: 20px;
|
|
84
|
+
margin-bottom: 15px;
|
|
85
|
+
padding-bottom: 15px;
|
|
86
|
+
border-bottom: 1px solid #444;
|
|
87
|
+
align-items: center;
|
|
88
|
+
flex-wrap: wrap;
|
|
89
|
+
font-size: 0.9em;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.chart-wrapper {
|
|
93
|
+
width: 100%;
|
|
94
|
+
display: flex;
|
|
95
|
+
justify-content: center;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.chart-container {
|
|
99
|
+
position: relative;
|
|
100
|
+
width: 100%;
|
|
101
|
+
height: 400px;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.chart-container.square-mode {
|
|
105
|
+
width: 100% !important;
|
|
106
|
+
height: auto !important;
|
|
107
|
+
aspect-ratio: 1 / 1;
|
|
108
|
+
max-width: 500px;
|
|
109
|
+
max-height: 500px;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
pre code {
|
|
113
|
+
display: block;
|
|
114
|
+
background: #000;
|
|
115
|
+
padding: 15px;
|
|
116
|
+
overflow-x: auto;
|
|
117
|
+
color: #a5d6ff;
|
|
118
|
+
font-family: 'Courier New', Courier, monospace;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
button,
|
|
122
|
+
select,
|
|
123
|
+
input,
|
|
124
|
+
#run-warnings-container {
|
|
125
|
+
border-radius: 0 !important;
|
|
126
|
+
}
|
|
127
|
+
</style>
|
|
128
|
+
</head>
|
|
129
|
+
|
|
130
|
+
<body>
|
|
131
|
+
<div class="sidebar" id="sidebar">
|
|
132
|
+
<h3 style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
|
133
|
+
Experiment Runs
|
|
134
|
+
</h3>
|
|
135
|
+
<button id="clear-all-btn"
|
|
136
|
+
style="margin-bottom: 15px; padding: 6px; background: #333; color: #eee; border: 1px solid #555; cursor: pointer;">Clear
|
|
137
|
+
All</button>
|
|
138
|
+
<div id="runs-list"></div>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<div class="main-content">
|
|
142
|
+
<div class="header-row">
|
|
143
|
+
<h2 id="run-title-display">Select runs to compare</h2>
|
|
144
|
+
<div style="display: flex; gap: 15px; align-items: center;">
|
|
145
|
+
<div class="layout-control-container">
|
|
146
|
+
<label for="non-line-select">Non-Line Charts:</label>
|
|
147
|
+
<select id="non-line-select"
|
|
148
|
+
style="padding: 5px; background: #333; color: #eee; border: 1px solid #555;">
|
|
149
|
+
<option value="latest">Show Latest</option>
|
|
150
|
+
<option value="all">Show All</option>
|
|
151
|
+
<option value="none">Show None</option>
|
|
152
|
+
</select>
|
|
153
|
+
</div>
|
|
154
|
+
<div class="layout-control-container">
|
|
155
|
+
<label for="layout-select">Layout:</label>
|
|
156
|
+
<select id="layout-select"
|
|
157
|
+
style="padding: 5px; background: #333; color: #eee; border: 1px solid #555;">
|
|
158
|
+
<option value="auto">Auto (Smart)</option>
|
|
159
|
+
<option value="1">1 Column</option>
|
|
160
|
+
<option value="2">2 Columns</option>
|
|
161
|
+
<option value="3">3 Columns</option>
|
|
162
|
+
</select>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
<div id="run-warnings-container"
|
|
167
|
+
style="display: none; background: rgba(245, 158, 11, 0.1); border: 1px solid #f59e0b; color: #f59e0b; padding: 15px; margin-bottom: 20px;">
|
|
168
|
+
</div>
|
|
169
|
+
<div id="dynamic-charts-container"></div>
|
|
170
|
+
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<script>
|
|
174
|
+
const RUN_COLORS = [
|
|
175
|
+
'#3b82f6', '#22c55e', '#ef4444', '#a855f7', '#f59e0b',
|
|
176
|
+
'#ec4899', '#06b6d4', '#10b981', '#f97316', '#6366f1'
|
|
177
|
+
];
|
|
178
|
+
const DASH_PATTERNS = [[], [5, 5], [2, 2], [8, 4], [12, 6]];
|
|
179
|
+
let allRuns = [];
|
|
180
|
+
let selectedRuns = [];
|
|
181
|
+
let chartInstances = {};
|
|
182
|
+
let chartConfigs = {};
|
|
183
|
+
|
|
184
|
+
function isOverlayable(type) {
|
|
185
|
+
return ['line', 'line_multi', 'scatter'].includes(type);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function parseCSV(text) {
|
|
189
|
+
const lines = text.trim().split('\n');
|
|
190
|
+
if (lines.length === 0 || lines[0] === '') return null;
|
|
191
|
+
const headers = lines[0].split(',').map(h => h.trim());
|
|
192
|
+
const rows = [];
|
|
193
|
+
for (let i = 1; i < lines.length; i++) {
|
|
194
|
+
const cols = lines[i].split(',');
|
|
195
|
+
if (cols.length > 1) rows.push(cols);
|
|
196
|
+
}
|
|
197
|
+
return { headers, rows };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function computeEqualAspectRange(datasets, options) {
|
|
201
|
+
const allVals = [];
|
|
202
|
+
datasets.forEach(ds => ds.data.forEach(d => { allVals.push(d.x); allVals.push(d.y); }));
|
|
203
|
+
const valid = allVals.filter(v => !isNaN(v));
|
|
204
|
+
if (valid.length === 0) return;
|
|
205
|
+
const globalMin = Math.min(...valid);
|
|
206
|
+
const globalMax = Math.max(...valid);
|
|
207
|
+
const padding = (globalMax - globalMin) * 0.1 || 1;
|
|
208
|
+
options.scales.x.min = globalMin - padding;
|
|
209
|
+
options.scales.x.max = globalMax + padding;
|
|
210
|
+
options.scales.y.min = globalMin - padding;
|
|
211
|
+
options.scales.y.max = globalMax + padding;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function esc(s) {
|
|
215
|
+
if (s == null) return '';
|
|
216
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function calculateEMA(data, weight) {
|
|
220
|
+
if (weight <= 0) return data;
|
|
221
|
+
if (data.length === 0) return [];
|
|
222
|
+
let smoothed = [];
|
|
223
|
+
let current = null;
|
|
224
|
+
for (let i = 0; i < data.length; i++) {
|
|
225
|
+
if (data[i] === null || isNaN(data[i])) {
|
|
226
|
+
smoothed.push(data[i]);
|
|
227
|
+
} else {
|
|
228
|
+
if (current === null) current = data[i];
|
|
229
|
+
else current = current * weight + data[i] * (1 - weight);
|
|
230
|
+
smoothed.push(current);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return smoothed;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function fetchRuns() {
|
|
237
|
+
try {
|
|
238
|
+
const response = await fetch('/api/runs');
|
|
239
|
+
const runs = await response.json();
|
|
240
|
+
allRuns = runs;
|
|
241
|
+
const list = document.getElementById('runs-list');
|
|
242
|
+
list.innerHTML = '';
|
|
243
|
+
runs.forEach((run, index) => {
|
|
244
|
+
const div = document.createElement('div');
|
|
245
|
+
div.className = 'run-item';
|
|
246
|
+
const scriptText = run.source_script ? `<br><small style="color: #a855f7;">Script: ${esc(run.source_script)}</small>` : '';
|
|
247
|
+
div.innerHTML = `
|
|
248
|
+
<label style="display: flex; align-items: flex-start; gap: 10px; cursor: pointer; width: 100%; margin: 0;">
|
|
249
|
+
<input type="checkbox" class="run-checkbox" value="${index}" style="margin-top: 4px;">
|
|
250
|
+
<div style="flex: 1;">
|
|
251
|
+
<b>${esc(run.experiment_name) || 'Experiment'} #${esc(run.experiment_number) || '?'}</b><br><small>${esc(run.timestamp)}</small><br><small>Commit: ${esc(run.git_commit)}</small>${scriptText}
|
|
252
|
+
</div>
|
|
253
|
+
</label>
|
|
254
|
+
`;
|
|
255
|
+
const checkbox = div.querySelector('input');
|
|
256
|
+
checkbox.addEventListener('change', () => {
|
|
257
|
+
if (checkbox.checked) {
|
|
258
|
+
div.classList.add('active');
|
|
259
|
+
if (!selectedRuns.includes(run)) selectedRuns.push(run);
|
|
260
|
+
} else {
|
|
261
|
+
div.classList.remove('active');
|
|
262
|
+
selectedRuns = selectedRuns.filter(r => r !== run);
|
|
263
|
+
}
|
|
264
|
+
selectedRuns.sort((a, b) => allRuns.indexOf(a) - allRuns.indexOf(b));
|
|
265
|
+
loadMultipleRunsData();
|
|
266
|
+
});
|
|
267
|
+
list.appendChild(div);
|
|
268
|
+
});
|
|
269
|
+
if (runs.length > 0) {
|
|
270
|
+
const firstCb = list.querySelector('input');
|
|
271
|
+
firstCb.checked = true;
|
|
272
|
+
firstCb.dispatchEvent(new Event('change'));
|
|
273
|
+
}
|
|
274
|
+
} catch (err) {
|
|
275
|
+
console.error("Failed to fetch runs:", err);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
document.getElementById('clear-all-btn').addEventListener('click', () => {
|
|
280
|
+
document.querySelectorAll('.run-checkbox').forEach(cb => {
|
|
281
|
+
if (cb.checked) {
|
|
282
|
+
cb.checked = false;
|
|
283
|
+
cb.dispatchEvent(new Event('change'));
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
document.getElementById('non-line-select').addEventListener('change', () => {
|
|
289
|
+
loadMultipleRunsData();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
function createChartDOM(id, title, type, layout) {
|
|
293
|
+
layout = layout || {};
|
|
294
|
+
let controlsHtml = '';
|
|
295
|
+
|
|
296
|
+
if (type === 'line' || type === 'line_multi') {
|
|
297
|
+
controlsHtml += `<label style="cursor: pointer;"><input type="checkbox" id="log_${id}" onchange="updateChart('${id}')"> Log Y Scale</label>`;
|
|
298
|
+
controlsHtml += `<label style="display: flex; align-items: center; gap: 5px;">Smoothing: <input type="range" id="smooth_${id}" min="0" max="0.99" step="0.01" value="0.0" oninput="updateChart('${id}')"></label>`;
|
|
299
|
+
}
|
|
300
|
+
if (layout.equal_aspect) {
|
|
301
|
+
controlsHtml += `<label style="cursor: pointer;"><input type="checkbox" id="equal_${id}" onchange="updateChart('${id}')" checked> Equal X/Y Scale</label>`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return `
|
|
305
|
+
<div class="chart-box" id="box_${id}">
|
|
306
|
+
<div class="chart-controls">
|
|
307
|
+
<b style="color: #3b82f6;">${title}</b>
|
|
308
|
+
${controlsHtml}
|
|
309
|
+
</div>
|
|
310
|
+
<div class="chart-wrapper">
|
|
311
|
+
<div class="chart-container ${layout.equal_aspect ? 'square-mode' : ''}" id="container_${id}">
|
|
312
|
+
<canvas id="canvas_${id}"></canvas>
|
|
313
|
+
</div>
|
|
314
|
+
</div>
|
|
315
|
+
</div>
|
|
316
|
+
`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
let currentLoadId = 0;
|
|
320
|
+
|
|
321
|
+
async function loadMultipleRunsData() {
|
|
322
|
+
const loadId = ++currentLoadId;
|
|
323
|
+
const container = document.getElementById('dynamic-charts-container');
|
|
324
|
+
const warningsContainer = document.getElementById('run-warnings-container');
|
|
325
|
+
|
|
326
|
+
if (selectedRuns.length === 0) {
|
|
327
|
+
document.getElementById('run-title-display').textContent = 'No runs selected';
|
|
328
|
+
container.innerHTML = '';
|
|
329
|
+
if (warningsContainer) warningsContainer.style.display = 'none';
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
let titleText = `Comparing ${selectedRuns.length} run(s)`;
|
|
334
|
+
if (selectedRuns.length === 1) {
|
|
335
|
+
titleText = `${selectedRuns[0].experiment_name || 'Experiment'} (Experiment #${selectedRuns[0].experiment_number || '?'})`;
|
|
336
|
+
if (selectedRuns[0].source_script) titleText += ` — Script: ${selectedRuns[0].source_script}`;
|
|
337
|
+
}
|
|
338
|
+
document.getElementById('run-title-display').textContent = titleText;
|
|
339
|
+
|
|
340
|
+
container.innerHTML = '';
|
|
341
|
+
if (warningsContainer) {
|
|
342
|
+
warningsContainer.style.display = 'none';
|
|
343
|
+
warningsContainer.innerHTML = '';
|
|
344
|
+
}
|
|
345
|
+
Object.values(chartInstances).forEach(c => c.destroy());
|
|
346
|
+
chartInstances = {};
|
|
347
|
+
chartConfigs = {};
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
const nonLineMode = document.getElementById('non-line-select').value;
|
|
351
|
+
|
|
352
|
+
const runPromises = selectedRuns.map(async (run) => {
|
|
353
|
+
const runIdx = allRuns.indexOf(run);
|
|
354
|
+
const runCharts = {};
|
|
355
|
+
const filesToFetch = new Set();
|
|
356
|
+
|
|
357
|
+
// Fetch metrics.jsonl if present in run files
|
|
358
|
+
if (!run.files || run.files.includes('metrics.jsonl')) {
|
|
359
|
+
filesToFetch.add('metrics.jsonl');
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Scan run files for any confusion matrix files
|
|
363
|
+
if (run.files) {
|
|
364
|
+
run.files.forEach(filename => {
|
|
365
|
+
if (filename.endsWith('confusion.jsonl')) {
|
|
366
|
+
filesToFetch.add(filename);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Retain support for any explicitly registered charts in meta.json
|
|
372
|
+
if (run.charts && run.charts.length > 0) {
|
|
373
|
+
run.charts.forEach(chart => {
|
|
374
|
+
if (chart.filename) {
|
|
375
|
+
filesToFetch.add(chart.filename);
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
for (const filename of filesToFetch) {
|
|
381
|
+
const response = await fetch(`/${run.folder}/${filename}`);
|
|
382
|
+
if (!response.ok) continue;
|
|
383
|
+
const text = await response.text();
|
|
384
|
+
const rows = text.trim().split('\n');
|
|
385
|
+
if (rows.length === 0 || rows[0] === "") continue;
|
|
386
|
+
|
|
387
|
+
if (filename.endsWith('confusion.jsonl')) {
|
|
388
|
+
const id = `confusion_${filename.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
|
389
|
+
runCharts[id] = {
|
|
390
|
+
type: 'heatmap',
|
|
391
|
+
title: filename === 'confusion.jsonl' ? 'Confusion Matrix' : `Confusion Matrix (${filename})`,
|
|
392
|
+
layout: {},
|
|
393
|
+
series: [],
|
|
394
|
+
raw_csv: text
|
|
395
|
+
};
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (filename.endsWith('.jsonl')) {
|
|
400
|
+
// Parse JSONL file
|
|
401
|
+
const jsonObjects = rows.map(line => {
|
|
402
|
+
try { return JSON.parse(line); } catch(e) { return null; }
|
|
403
|
+
}).filter(obj => obj !== null);
|
|
404
|
+
|
|
405
|
+
if (jsonObjects.length === 0) continue;
|
|
406
|
+
|
|
407
|
+
// Collect all unique keys (excluding epoch and step)
|
|
408
|
+
const allKeys = new Set();
|
|
409
|
+
jsonObjects.forEach(obj => {
|
|
410
|
+
Object.keys(obj).forEach(k => {
|
|
411
|
+
if (k !== 'epoch' && k !== 'step') allKeys.add(k);
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const epochs = [];
|
|
416
|
+
const metricData = {};
|
|
417
|
+
allKeys.forEach(k => {
|
|
418
|
+
metricData[k] = { values: [] };
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
jsonObjects.forEach((obj, i) => {
|
|
422
|
+
const epochVal = obj.epoch !== undefined ? obj.epoch : (obj.step !== undefined ? obj.step : i);
|
|
423
|
+
epochs.push(epochVal);
|
|
424
|
+
allKeys.forEach(k => {
|
|
425
|
+
metricData[k].values.push(obj[k] !== undefined ? obj[k] : null);
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
// Track columns claimed by manually defined custom charts
|
|
430
|
+
const claimedColumns = new Set();
|
|
431
|
+
const fileCharts = (run.charts || []).filter(c => c.filename === filename);
|
|
432
|
+
fileCharts.forEach(chart => {
|
|
433
|
+
if (chart.series) chart.series.forEach(s => claimedColumns.add(s.y));
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// Prefix Auto-Grouping: group keys by their first namespace parts (divided by '/' or '.')
|
|
437
|
+
const groups = {};
|
|
438
|
+
const singleMetrics = [];
|
|
439
|
+
|
|
440
|
+
Object.keys(metricData).forEach(metricName => {
|
|
441
|
+
if (claimedColumns.has(metricName)) return;
|
|
442
|
+
|
|
443
|
+
const parts = metricName.split(/[\/\.]/);
|
|
444
|
+
if (parts.length > 1) {
|
|
445
|
+
const prefix = parts[0];
|
|
446
|
+
const suffix = parts.slice(1).join('/');
|
|
447
|
+
if (!groups[prefix]) groups[prefix] = [];
|
|
448
|
+
groups[prefix].push({ y: metricName, name: suffix });
|
|
449
|
+
} else {
|
|
450
|
+
singleMetrics.push(metricName);
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
// 1. Create multi-series line charts for auto-grouped keys
|
|
455
|
+
Object.keys(groups).forEach(prefix => {
|
|
456
|
+
const id = `auto_group_${filename.replace(/[^a-zA-Z0-9]/g, '_')}_${prefix.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
|
457
|
+
const dsData = {};
|
|
458
|
+
const series = groups[prefix];
|
|
459
|
+
series.forEach(s => {
|
|
460
|
+
dsData[s.y] = metricData[s.y].values;
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
runCharts[id] = {
|
|
464
|
+
type: 'line_multi',
|
|
465
|
+
title: `${prefix} (${filename})`,
|
|
466
|
+
layout: {},
|
|
467
|
+
series: series,
|
|
468
|
+
epochs,
|
|
469
|
+
datasetsData: dsData
|
|
470
|
+
};
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// 2. Create single line charts for ungrouped keys
|
|
474
|
+
singleMetrics.forEach(metricName => {
|
|
475
|
+
const id = `metric_${filename.replace(/[^a-zA-Z0-9]/g, '_')}_${metricName.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
|
476
|
+
runCharts[id] = {
|
|
477
|
+
type: 'line',
|
|
478
|
+
title: `${metricName} (${filename})`,
|
|
479
|
+
epochs,
|
|
480
|
+
values: metricData[metricName].values,
|
|
481
|
+
layout: {}
|
|
482
|
+
};
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// 3. Keep custom manually defined charts (e.g. from meta.json)
|
|
486
|
+
fileCharts.forEach(chart => {
|
|
487
|
+
if (chart.series && chart.series.length > 0) {
|
|
488
|
+
const id = `custom_${chart.title.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
|
489
|
+
const dsData = {};
|
|
490
|
+
chart.series.forEach(s => {
|
|
491
|
+
if (metricData[s.y]) dsData[s.y] = metricData[s.y].values;
|
|
492
|
+
});
|
|
493
|
+
runCharts[id] = { type: 'line_multi', title: chart.title, layout: chart.layout || {}, series: chart.series, epochs, datasetsData: dsData };
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return { run, runIdx, runCharts };
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
const results = await Promise.all(runPromises);
|
|
503
|
+
if (loadId !== currentLoadId) return;
|
|
504
|
+
|
|
505
|
+
results.forEach(res => {
|
|
506
|
+
const { run, runIdx, runCharts } = res;
|
|
507
|
+
const runName = run.experiment_name || `Experiment #${run.experiment_number || runIdx}`;
|
|
508
|
+
|
|
509
|
+
Object.keys(runCharts).forEach(id => {
|
|
510
|
+
const chartData = runCharts[id];
|
|
511
|
+
let targetId = id;
|
|
512
|
+
|
|
513
|
+
if (!isOverlayable(chartData.type)) {
|
|
514
|
+
if (nonLineMode === 'none') return;
|
|
515
|
+
if (nonLineMode === 'all') {
|
|
516
|
+
targetId = `${id}_run${runIdx}`;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (!chartConfigs[targetId]) {
|
|
521
|
+
chartConfigs[targetId] = {
|
|
522
|
+
type: chartData.type,
|
|
523
|
+
title: (nonLineMode === 'all' && !isOverlayable(chartData.type) && selectedRuns.length > 1) ? `${chartData.title} (${runName})` : chartData.title,
|
|
524
|
+
layout: chartData.layout,
|
|
525
|
+
series: chartData.series,
|
|
526
|
+
dataByRun: []
|
|
527
|
+
};
|
|
528
|
+
container.insertAdjacentHTML('beforeend', createChartDOM(targetId, chartConfigs[targetId].title, chartData.type, chartData.layout));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (nonLineMode === 'latest' && !isOverlayable(chartData.type)) {
|
|
532
|
+
if (chartConfigs[targetId].dataByRun.length === 0 || runIdx < chartConfigs[targetId].dataByRun[0].runIdx) {
|
|
533
|
+
chartConfigs[targetId].dataByRun = [{ runName, runIdx, ...chartData }];
|
|
534
|
+
}
|
|
535
|
+
} else {
|
|
536
|
+
chartConfigs[targetId].dataByRun.push({ runName, runIdx, ...chartData });
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
if (loadId !== currentLoadId) return;
|
|
542
|
+
|
|
543
|
+
Object.keys(chartConfigs).forEach(id => updateChart(id));
|
|
544
|
+
|
|
545
|
+
} catch (err) {
|
|
546
|
+
console.error("Error loading multiple runs data:", err);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
fetchRuns();
|
|
552
|
+
|
|
553
|
+
function updateChart(id) {
|
|
554
|
+
const config = chartConfigs[id];
|
|
555
|
+
if (!config) return;
|
|
556
|
+
|
|
557
|
+
const ctx = document.getElementById(`canvas_${id}`).getContext('2d');
|
|
558
|
+
if (chartInstances[id]) chartInstances[id].destroy();
|
|
559
|
+
|
|
560
|
+
const logEl = document.getElementById(`log_${id}`);
|
|
561
|
+
const isLog = logEl ? logEl.checked : false;
|
|
562
|
+
|
|
563
|
+
const smoothEl = document.getElementById(`smooth_${id}`);
|
|
564
|
+
const smoothWeight = smoothEl ? parseFloat(smoothEl.value) : 0;
|
|
565
|
+
|
|
566
|
+
const equalEl = document.getElementById(`equal_${id}`);
|
|
567
|
+
const isEqual = equalEl ? equalEl.checked : (config.layout && config.layout.equal_aspect || false);
|
|
568
|
+
|
|
569
|
+
if (isEqual) {
|
|
570
|
+
document.getElementById(`container_${id}`).classList.add('square-mode');
|
|
571
|
+
} else {
|
|
572
|
+
document.getElementById(`container_${id}`).classList.remove('square-mode');
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (config.type === 'line' || config.type === 'line_multi' || config.type === 'scatter') {
|
|
576
|
+
const datasets = [];
|
|
577
|
+
|
|
578
|
+
config.dataByRun.forEach(runData => {
|
|
579
|
+
const runName = runData.runName;
|
|
580
|
+
const c = RUN_COLORS[runData.runIdx % RUN_COLORS.length];
|
|
581
|
+
const c_raw = c + '40';
|
|
582
|
+
|
|
583
|
+
if (config.type === 'line' || config.type === 'line_multi') {
|
|
584
|
+
// Normalize 'line' into a single-series 'line_multi' shape
|
|
585
|
+
const seriesList = config.type === 'line'
|
|
586
|
+
? [{ name: config.dataByRun.length > 1 ? runName : 'Value', y: '__value__' }]
|
|
587
|
+
: config.series;
|
|
588
|
+
const getData = (col) => col === '__value__' ? runData.values : (runData.datasetsData && runData.datasetsData[col]);
|
|
589
|
+
|
|
590
|
+
let seriesIdx = 0;
|
|
591
|
+
seriesList.forEach(s => {
|
|
592
|
+
const seriesName = s.name || s.y;
|
|
593
|
+
const vals = getData(s.y);
|
|
594
|
+
if (!vals) return;
|
|
595
|
+
|
|
596
|
+
const rawPoints = runData.epochs.map((e, i) => ({ x: e, y: vals[i] }));
|
|
597
|
+
const smoothed = calculateEMA(vals, smoothWeight);
|
|
598
|
+
const smoothedPoints = runData.epochs.map((e, i) => ({ x: e, y: smoothed[i] }));
|
|
599
|
+
const dash = DASH_PATTERNS[seriesIdx % DASH_PATTERNS.length];
|
|
600
|
+
|
|
601
|
+
let label = config.dataByRun.length > 1 && config.type === 'line_multi' ? `${seriesName} (${runName})` : seriesName;
|
|
602
|
+
let rawLabel = config.dataByRun.length > 1 ? `${seriesName} (${config.type === 'line_multi' ? runName + ' ' : ''}Raw)` : `${seriesName === 'Value' ? '' : seriesName + ' '}Raw`;
|
|
603
|
+
// For single-series line with single run, use cleaner labels
|
|
604
|
+
if (config.type === 'line' && config.dataByRun.length === 1) { label = 'Smoothed'; rawLabel = 'Raw'; }
|
|
605
|
+
|
|
606
|
+
if (smoothWeight > 0) datasets.push({ label: rawLabel, data: rawPoints, borderColor: c_raw, backgroundColor: c_raw, borderWidth: 1, pointRadius: 0, showLine: true, borderDash: dash });
|
|
607
|
+
datasets.push({ label: label, data: smoothedPoints, borderColor: c, backgroundColor: c, borderWidth: 2, pointRadius: smoothWeight > 0 ? 0 : 1.5, showLine: true, borderDash: dash });
|
|
608
|
+
seriesIdx++;
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
else if (config.type === 'scatter') {
|
|
612
|
+
const csv = parseCSV(runData.raw_csv);
|
|
613
|
+
if (!csv) return;
|
|
614
|
+
|
|
615
|
+
config.series.forEach((s, sIdx) => {
|
|
616
|
+
const xIdx = csv.headers.indexOf(s.x);
|
|
617
|
+
const yIdx = csv.headers.indexOf(s.y);
|
|
618
|
+
if (xIdx === -1 || yIdx === -1) return;
|
|
619
|
+
|
|
620
|
+
const data = [];
|
|
621
|
+
csv.rows.forEach(cols => {
|
|
622
|
+
if (cols.length > Math.max(xIdx, yIdx)) {
|
|
623
|
+
const xVal = parseFloat(cols[xIdx]);
|
|
624
|
+
const yVal = parseFloat(cols[yIdx]);
|
|
625
|
+
if (!isNaN(xVal) && !isNaN(yVal)) data.push({ x: xVal, y: yVal });
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
const labelPrefix = config.dataByRun.length > 1 ? `${s.name || 'Series'} (${runName})` : (s.name || `Series ${sIdx + 1}`);
|
|
630
|
+
const showLines = config.layout && config.layout.show_lines;
|
|
631
|
+
const dash = showLines ? DASH_PATTERNS[sIdx % DASH_PATTERNS.length] : [];
|
|
632
|
+
const seriesColor = config.dataByRun.length > 1 ? c : RUN_COLORS[sIdx % RUN_COLORS.length];
|
|
633
|
+
|
|
634
|
+
datasets.push({
|
|
635
|
+
label: labelPrefix, data, borderColor: seriesColor, backgroundColor: seriesColor,
|
|
636
|
+
showLine: !!showLines, borderWidth: showLines ? 1.5 : undefined,
|
|
637
|
+
pointRadius: 2.5, borderDash: dash
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
let options = {
|
|
644
|
+
responsive: true, maintainAspectRatio: !isEqual, aspectRatio: isEqual ? 1 : undefined, animation: false, color: '#eee',
|
|
645
|
+
scales: {
|
|
646
|
+
x: { type: 'linear', ticks: { color: '#aaa' }, grid: { color: '#333' } },
|
|
647
|
+
y: { type: isLog ? 'logarithmic' : 'linear', ticks: { color: '#aaa' }, grid: { color: '#333' } }
|
|
648
|
+
},
|
|
649
|
+
plugins: {
|
|
650
|
+
legend: {
|
|
651
|
+
labels: {
|
|
652
|
+
usePointStyle: true,
|
|
653
|
+
color: '#eee',
|
|
654
|
+
padding: 15,
|
|
655
|
+
font: { size: 12 },
|
|
656
|
+
generateLabels(chart) {
|
|
657
|
+
return chart.data.datasets.map((ds, i) => {
|
|
658
|
+
const meta = chart.getDatasetMeta(i);
|
|
659
|
+
return {
|
|
660
|
+
text: ds.label,
|
|
661
|
+
fontColor: '#eee',
|
|
662
|
+
fillStyle: ds.borderColor,
|
|
663
|
+
strokeStyle: ds.borderColor,
|
|
664
|
+
lineWidth: ds.showLine ? (ds.borderWidth || 2) : 0,
|
|
665
|
+
lineDash: ds.borderDash || [],
|
|
666
|
+
pointStyle: ds.showLine ? 'line' : 'circle',
|
|
667
|
+
hidden: meta.hidden,
|
|
668
|
+
datasetIndex: i
|
|
669
|
+
};
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
if (!isEqual) {
|
|
677
|
+
options.maintainAspectRatio = false;
|
|
678
|
+
delete options.aspectRatio;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (config.type === 'scatter' && isEqual) {
|
|
682
|
+
computeEqualAspectRange(datasets, options);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
chartInstances[id] = new Chart(ctx, {
|
|
686
|
+
type: 'line',
|
|
687
|
+
data: { datasets: datasets },
|
|
688
|
+
options: options
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
else if (config.type === 'heatmap') {
|
|
692
|
+
const runData = config.dataByRun[0];
|
|
693
|
+
if (!runData) return;
|
|
694
|
+
|
|
695
|
+
const rows = runData.raw_csv.trim().split('\n');
|
|
696
|
+
const jsonObjects = rows.map(line => {
|
|
697
|
+
try { return JSON.parse(line); } catch(e) { return null; }
|
|
698
|
+
}).filter(obj => obj !== null);
|
|
699
|
+
|
|
700
|
+
if (jsonObjects.length === 0) return;
|
|
701
|
+
|
|
702
|
+
const matrixData = [];
|
|
703
|
+
let maxCount = 0;
|
|
704
|
+
|
|
705
|
+
// Dynamically collect class labels sorted by their index
|
|
706
|
+
const classLabelMap = {};
|
|
707
|
+
jsonObjects.forEach(obj => {
|
|
708
|
+
if (obj.true_class !== undefined && obj.true_label !== undefined) {
|
|
709
|
+
classLabelMap[obj.true_class] = obj.true_label;
|
|
710
|
+
}
|
|
711
|
+
if (obj.pred_class !== undefined && obj.pred_label !== undefined) {
|
|
712
|
+
classLabelMap[obj.pred_class] = obj.pred_label;
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
// Extract unique indices, sort them, and map to label strings
|
|
717
|
+
const sortedIndices = Object.keys(classLabelMap).map(Number).sort((a, b) => a - b);
|
|
718
|
+
const classLabels = sortedIndices.map(idx => classLabelMap[idx]);
|
|
719
|
+
|
|
720
|
+
jsonObjects.forEach(obj => {
|
|
721
|
+
const x_idx = obj.pred_class;
|
|
722
|
+
const y_idx = obj.true_class;
|
|
723
|
+
const count = obj.count;
|
|
724
|
+
|
|
725
|
+
if (x_idx === undefined || y_idx === undefined || count === undefined) return;
|
|
726
|
+
|
|
727
|
+
if (count > maxCount) maxCount = count;
|
|
728
|
+
|
|
729
|
+
matrixData.push({
|
|
730
|
+
x: classLabelMap[x_idx] || `Class ${x_idx}`,
|
|
731
|
+
y: classLabelMap[y_idx] || `Class ${y_idx}`,
|
|
732
|
+
v: count
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
const numClasses = classLabels.length || 1;
|
|
737
|
+
|
|
738
|
+
chartInstances[id] = new Chart(ctx, {
|
|
739
|
+
type: 'matrix',
|
|
740
|
+
data: {
|
|
741
|
+
datasets: [{
|
|
742
|
+
label: config.title,
|
|
743
|
+
data: matrixData,
|
|
744
|
+
backgroundColor(context) {
|
|
745
|
+
const value = context.dataset.data[context.dataIndex].v;
|
|
746
|
+
const alpha = maxCount > 0 ? (0.15 + 0.85 * value / maxCount) : 0;
|
|
747
|
+
return `rgba(59, 130, 246, ${alpha})`;
|
|
748
|
+
},
|
|
749
|
+
borderColor: '#333',
|
|
750
|
+
borderWidth: 2,
|
|
751
|
+
width: ({ chart }) => {
|
|
752
|
+
const area = chart.chartArea;
|
|
753
|
+
if (!area) return 0;
|
|
754
|
+
return (area.width / numClasses) - 4;
|
|
755
|
+
},
|
|
756
|
+
height: ({ chart }) => {
|
|
757
|
+
const area = chart.chartArea;
|
|
758
|
+
if (!area) return 0;
|
|
759
|
+
return (area.height / numClasses) - 4;
|
|
760
|
+
}
|
|
761
|
+
}]
|
|
762
|
+
},
|
|
763
|
+
options: {
|
|
764
|
+
responsive: true, maintainAspectRatio: false, color: '#eee',
|
|
765
|
+
scales: {
|
|
766
|
+
x: {
|
|
767
|
+
type: 'category',
|
|
768
|
+
title: { display: true, text: 'Predicted Class', color: '#eee' },
|
|
769
|
+
ticks: { color: '#aaa' },
|
|
770
|
+
grid: { display: false },
|
|
771
|
+
offset: true
|
|
772
|
+
},
|
|
773
|
+
y: {
|
|
774
|
+
type: 'category',
|
|
775
|
+
title: { display: true, text: 'True Class', color: '#eee' },
|
|
776
|
+
ticks: { color: '#aaa' },
|
|
777
|
+
grid: { display: false },
|
|
778
|
+
offset: true,
|
|
779
|
+
reverse: true
|
|
780
|
+
}
|
|
781
|
+
},
|
|
782
|
+
plugins: {
|
|
783
|
+
tooltip: {
|
|
784
|
+
callbacks: {
|
|
785
|
+
title() { return ''; },
|
|
786
|
+
label(context) {
|
|
787
|
+
const v = context.dataset.data[context.dataIndex];
|
|
788
|
+
return ['True: ' + v.y, 'Pred: ' + v.x, 'Count: ' + v.v];
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
},
|
|
794
|
+
plugins: [{
|
|
795
|
+
id: 'matrixText',
|
|
796
|
+
afterDatasetsDraw(chart) {
|
|
797
|
+
const ctx = chart.ctx;
|
|
798
|
+
chart.data.datasets.forEach((dataset, i) => {
|
|
799
|
+
const meta = chart.getDatasetMeta(i);
|
|
800
|
+
meta.data.forEach((element, index) => {
|
|
801
|
+
const value = dataset.data[index].v;
|
|
802
|
+
const cellW = element.width || 0;
|
|
803
|
+
const cellH = element.height || 0;
|
|
804
|
+
ctx.fillStyle = '#fff';
|
|
805
|
+
ctx.font = 'bold 14px monospace';
|
|
806
|
+
ctx.textAlign = 'center';
|
|
807
|
+
ctx.textBaseline = 'middle';
|
|
808
|
+
ctx.fillText(value, element.x + cellW / 2, element.y + cellH / 2);
|
|
809
|
+
});
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
}]
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
document.getElementById('layout-select').addEventListener('change', function (e) {
|
|
817
|
+
const val = e.target.value;
|
|
818
|
+
const container = document.getElementById('dynamic-charts-container');
|
|
819
|
+
if (val === 'auto') {
|
|
820
|
+
container.style.gridTemplateColumns = 'repeat(auto-fit, minmax(450px, 1fr))';
|
|
821
|
+
} else {
|
|
822
|
+
container.style.gridTemplateColumns = `repeat(${val}, 1fr)`;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
setTimeout(() => {
|
|
826
|
+
Object.values(chartInstances).forEach(chart => chart.resize());
|
|
827
|
+
}, 100);
|
|
828
|
+
});
|
|
829
|
+
</script>
|
|
830
|
+
</body>
|
|
831
|
+
|
|
832
|
+
</html>
|
mtrick/tracker.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
class MTracker:
|
|
8
|
+
def __init__(
|
|
9
|
+
self,
|
|
10
|
+
experiment_name: str,
|
|
11
|
+
save_path: str = "metrics",
|
|
12
|
+
):
|
|
13
|
+
self.experiment_name = experiment_name
|
|
14
|
+
self.save_path = save_path
|
|
15
|
+
|
|
16
|
+
# Autogenerated metadata
|
|
17
|
+
self.timestamp: str = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
18
|
+
self.git_commit: str = self.get_git_commit()
|
|
19
|
+
|
|
20
|
+
# Determine experiment number from the highest existing exp* directory
|
|
21
|
+
os.makedirs(self.save_path, exist_ok=True)
|
|
22
|
+
self.experiment_number = self.get_experiment_number()
|
|
23
|
+
|
|
24
|
+
dir_name = f"exp{self.experiment_number:03d}_{self.experiment_name}_{self.timestamp}"
|
|
25
|
+
self.experiment_dir = os.path.join(self.save_path, dir_name)
|
|
26
|
+
try:
|
|
27
|
+
os.mkdir(self.experiment_dir)
|
|
28
|
+
except FileExistsError:
|
|
29
|
+
raise RuntimeError(
|
|
30
|
+
f"Failed to create a unique run directory {self.save_path}"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Metadata dump
|
|
34
|
+
self.meta = {
|
|
35
|
+
"experiment_name": self.experiment_name,
|
|
36
|
+
"experiment_number": self.experiment_number,
|
|
37
|
+
"timestamp": self.timestamp,
|
|
38
|
+
"git_commit": self.git_commit,
|
|
39
|
+
}
|
|
40
|
+
with open(os.path.join(self.experiment_dir, "meta.json"), "w") as f:
|
|
41
|
+
json.dump(self.meta, f, indent=4)
|
|
42
|
+
|
|
43
|
+
print(f"Tracking experiment at: {self.experiment_dir}")
|
|
44
|
+
|
|
45
|
+
def get_git_commit(self) -> str:
|
|
46
|
+
"""Returns the current short git commit hash, appending '-dirty' if uncommitted changes exist."""
|
|
47
|
+
try:
|
|
48
|
+
commit_hash = (
|
|
49
|
+
subprocess.check_output(["git", "rev-parse", "--short", "HEAD"])
|
|
50
|
+
.decode("ascii")
|
|
51
|
+
.strip()
|
|
52
|
+
)
|
|
53
|
+
status = (
|
|
54
|
+
subprocess.check_output(["git", "status", "--porcelain"])
|
|
55
|
+
.decode("ascii")
|
|
56
|
+
.strip()
|
|
57
|
+
)
|
|
58
|
+
if status:
|
|
59
|
+
return f"{commit_hash}-dirty"
|
|
60
|
+
return commit_hash
|
|
61
|
+
except Exception:
|
|
62
|
+
return "unknown"
|
|
63
|
+
|
|
64
|
+
def get_experiment_number(self) -> int:
|
|
65
|
+
max_exp_num = 0
|
|
66
|
+
for path in os.listdir(self.save_path):
|
|
67
|
+
match = re.match(r"^exp(\d+)_", path)
|
|
68
|
+
if match:
|
|
69
|
+
max_exp_num = max(max_exp_num, int(match.group(1)))
|
|
70
|
+
|
|
71
|
+
return max_exp_num + 1
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def log(
|
|
75
|
+
self,
|
|
76
|
+
metrics: dict[str, float],
|
|
77
|
+
epoch: int,
|
|
78
|
+
):
|
|
79
|
+
"""
|
|
80
|
+
Simplified logging API.
|
|
81
|
+
"""
|
|
82
|
+
print(f"{epoch:4d}| {metrics}")
|
|
83
|
+
|
|
84
|
+
log_entry: dict[str, float] = {"epoch": epoch, **metrics}
|
|
85
|
+
metrics_path = os.path.join(self.experiment_dir, "metrics.jsonl")
|
|
86
|
+
with open(metrics_path, "a") as f:
|
|
87
|
+
f.write(json.dumps(log_entry) + "\n")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def log_trajectory(
|
|
92
|
+
self,
|
|
93
|
+
true_data: list[list[float]],
|
|
94
|
+
pred_data: list[list[float]],
|
|
95
|
+
):
|
|
96
|
+
"""
|
|
97
|
+
Saves a 2D trajectory of ground truth vs predicted data to a JSON file.
|
|
98
|
+
true_data: numpy array of shape (seq_len, 2+)
|
|
99
|
+
pred_data: numpy array of shape (seq_len, 2+)
|
|
100
|
+
"""
|
|
101
|
+
data: dict[str, list[list[float]]] = {
|
|
102
|
+
"true": [[float(p[0]), float(p[1])] for p in true_data],
|
|
103
|
+
"pred": [[float(p[0]), float(p[1])] for p in pred_data]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
filename = "trajectory.json"
|
|
107
|
+
json_path = os.path.join(self.experiment_dir, filename)
|
|
108
|
+
|
|
109
|
+
with open(json_path, "w") as f:
|
|
110
|
+
json.dump(data, f)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def log_confusion_matrix(
|
|
114
|
+
self,
|
|
115
|
+
matrix_data,
|
|
116
|
+
classes=None,
|
|
117
|
+
title="Confusion Matrix",
|
|
118
|
+
filename="confusion.jsonl",
|
|
119
|
+
):
|
|
120
|
+
"""
|
|
121
|
+
Saves a confusion matrix to JSONL.
|
|
122
|
+
matrix_data: 2D numpy array or nested list [true_class][pred_class]
|
|
123
|
+
classes: List of string labels for the classes.
|
|
124
|
+
"""
|
|
125
|
+
jsonl_path = os.path.join(self.experiment_dir, filename)
|
|
126
|
+
num_classes = len(matrix_data)
|
|
127
|
+
if classes is None:
|
|
128
|
+
classes = [f"Class {i}" for i in range(num_classes)]
|
|
129
|
+
|
|
130
|
+
with open(jsonl_path, "w") as f:
|
|
131
|
+
for i in range(num_classes):
|
|
132
|
+
for j in range(num_classes):
|
|
133
|
+
row = {
|
|
134
|
+
"true_class": i,
|
|
135
|
+
"pred_class": j,
|
|
136
|
+
"count": int(matrix_data[i][j]),
|
|
137
|
+
"true_label": str(classes[i]),
|
|
138
|
+
"pred_label": str(classes[j])
|
|
139
|
+
}
|
|
140
|
+
f.write(json.dumps(row) + "\n")
|
|
141
|
+
|
|
142
|
+
print(f"Saved confusion matrix: {jsonl_path}")
|
mtrick/utils.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: mtrick
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A local-first, simple ML experiment tracker
|
|
5
|
+
Author: Avik Arefin
|
|
6
|
+
Author-email: Avik Arefin <avik.me.arefin@gmail.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Project-URL: Repository, https://github.com/AvikArefin/mtrick
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# mtrick: Metrics Tracker
|
|
16
|
+
A local-first simple ML experiment tracker with zero configurations required.
|
|
17
|
+
|
|
18
|
+

|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
uv add mtrick
|
|
24
|
+
```
|
|
25
|
+
or
|
|
26
|
+
```
|
|
27
|
+
pip install mtrick
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
If you want the latest version
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
uv add git+https://github.com/AvikArefin/mtrick.git
|
|
35
|
+
```
|
|
36
|
+
or
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install git+https://github.com/AvikArefin/mtrick.git
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
Launch the dashboard via:
|
|
44
|
+
```bash
|
|
45
|
+
mtrick
|
|
46
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
mtrick/__init__.py,sha256=DtuNXopfvhHgo8EojHJC0WS4DzVEBwzZWUhvtDCxCMQ,125
|
|
2
|
+
mtrick/dashboard.py,sha256=UfmIZN3Qe2kZ3-yzkrW6KtZ0zPFGrjW_S7kscmvrdtk,4954
|
|
3
|
+
mtrick/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
mtrick/static/index.html,sha256=W06j_k6VM_e2we2ssMFYTHyxoKcAn8GlAtlK8rYojrc,37906
|
|
5
|
+
mtrick/tracker.py,sha256=59DPMgFvRPA2tbb77cArO8zXnCet6_66T_j7JJtkTVA,4627
|
|
6
|
+
mtrick/utils.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
mtrick-0.1.0.dist-info/WHEEL,sha256=o6xtdofIa8Zz80kUveEHMWeAWtEyZSzYS1bbyKDCgzA,80
|
|
8
|
+
mtrick-0.1.0.dist-info/entry_points.txt,sha256=0YOBI8RCdeddujXWZKeikFdAoMOhO9ikiIj4swbvw4I,50
|
|
9
|
+
mtrick-0.1.0.dist-info/METADATA,sha256=IZBNfgfKPukXMvdFWMrU46Nru9CDCJyfM7GOr_qtFKI,899
|
|
10
|
+
mtrick-0.1.0.dist-info/RECORD,,
|