File size: 4,898 Bytes
00c55c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
"""Parse training log file and write metrics to TensorBoard event files.

Usage:
    # One-shot conversion
    python scripts/log_to_tensorboard.py --log /tmp/streampi_t3_robodojo_30k_v1_20260909.log

    # Watch mode (continuously monitor log for new entries)
    python scripts/log_to_tensorboard.py --log /tmp/streampi_t3_robodojo_30k_v1_20260909.log --watch

    # Custom output directory
    python scripts/log_to_tensorboard.py --log /tmp/run.log --output-dir runs/my_run
"""

import argparse
import os
import re
import time
from pathlib import Path

try:
  from torch.utils.tensorboard import SummaryWriter
except ImportError:
  from tensorboardX import SummaryWriter


def parse_step_line(line: str) -> dict | None:
    """Parse a log line like 'Step 0: grad_norm=4.0662, loss=0.4126, param_norm=1802.3864'.

    Returns a dict with 'step' (int) and metric key-value pairs (float), or None.
    """
    match = re.match(r"Step\s+(\d+):\s*(.+)", line.strip())
    if not match:
        return None

    step = int(match.group(1))
    metrics_str = match.group(2)

    result: dict = {"step": step}
    for pair in metrics_str.split(","):
        pair = pair.strip()
        if "=" not in pair:
            continue
        key, value = pair.split("=", 1)
        key = key.strip()
        value = value.strip()
        try:
            result[key] = float(value)
        except ValueError:
            continue

    return result


def parse_log_file(log_path: str, last_step: int = -1) -> list[dict]:
    """Return deduplicated entries with step > last_step.

    If a resumed run repeats a step, the last occurrence in the log is kept,
    because it belongs to the trajectory resumed from the latest checkpoint.
    """
    entries_by_step: dict[int, dict] = {}
    with open(log_path, "r", encoding="utf-8", errors="ignore") as f:
        for line in f:
            parsed = parse_step_line(line)
            if parsed is None:
                continue

            step = parsed["step"]
            if step <= last_step:
                continue

            # Later occurrences replace pre-resume entries at the same step.
            entries_by_step[step] = parsed

    return [entries_by_step[step] for step in sorted(entries_by_step)]


def write_entries(writer: SummaryWriter, entries: list[dict]) -> int:
    """Write parsed entries to TensorBoard. Returns the max step written."""
    max_step = -1
    for entry in entries:
        step = entry["step"]
        for key, value in entry.items():
            if key == "step":
                continue
            writer.add_scalar(key, value, step)
        max_step = max(max_step, step)

    writer.flush()
    return max_step


def main():
    parser = argparse.ArgumentParser(description="Convert training log to TensorBoard events.")
    parser.add_argument("--log", type=str, required=True, help="Path to the training log file.")
    parser.add_argument(
        "--output-dir",
        type=str,
        default=None,
        help="TensorBoard output directory. Defaults to runs/<log_filename_stem>.",
    )
    parser.add_argument(
        "--watch",
        action="store_true",
        help="Continuously monitor the log file for new entries.",
    )
    parser.add_argument(
        "--interval",
        type=float,
        default=10.0,
        help="Polling interval in seconds for --watch mode (default: 10).",
    )
    args = parser.parse_args()

    log_path = args.log
    if not os.path.exists(log_path):
        raise FileNotFoundError(f"Log file not found: {log_path}")

    log_stem = Path(log_path).stem
    output_dir = args.output_dir or os.path.join("runs", log_stem)
    os.makedirs(output_dir, exist_ok=True)

    writer = SummaryWriter(log_dir=output_dir)
    print(f"TensorBoard log dir: {os.path.abspath(output_dir)}")
    print(f"Monitoring log: {log_path}")

    # One-shot parse
    entries = parse_log_file(log_path)
    max_step = write_entries(writer, entries)
    print(f"Wrote {len(entries)} entries (max step: {max_step})")

    if not args.watch:
        writer.close()
        print(f"\nDone. Run: tensorboard --logdir {os.path.abspath(output_dir)}")
        return

    # Watch mode
    print(f"Watching for new entries (interval={args.interval}s). Press Ctrl+C to stop.")
    try:
        while True:
            time.sleep(args.interval)
            new_entries = parse_log_file(log_path, last_step=max_step)
            if new_entries:
                max_step = write_entries(writer, new_entries)
                print(f"  Updated: +{len(new_entries)} entries (max step: {max_step})")
    except KeyboardInterrupt:
        print("\nStopping watch mode.")
    finally:
        writer.close()
        print(f"TensorBoard events written to: {os.path.abspath(output_dir)}")
        print(f"Run: tensorboard --logdir {os.path.abspath(output_dir)}")


if __name__ == "__main__":
    main()