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 ADDED
@@ -0,0 +1,6 @@
1
+ from .tracker import MTracker
2
+
3
+ # For ease of import or standard naming
4
+ Tracker = MTracker
5
+
6
+ __all__ = ["MTracker", "Tracker"]
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
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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
+ ![Dashboard Interface](interface.png)
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.10.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ mtrick = mtrick.dashboard:main
3
+