minimind/scripts/parse_train_log.py

192 lines
6.1 KiB
Python

import argparse
import csv
import json
import os
import re
from statistics import mean
EPOCH_RE = re.compile(r"Epoch:\[(?P<epoch>\d+)/(?P<epochs>\d+)\]\((?P<step>\d+)/(?P<iters>\d+)\)")
KEY_VALUE_RE = re.compile(r"([A-Za-z_][A-Za-z0-9_ /-]*):\s*([-+]?\d+(?:\.\d+)?(?:e[-+]?\d+)?)", re.IGNORECASE)
SETUP_KEY_RE = re.compile(r"^\s{2}([^:]+):\s*(.*)$")
INLINE_KV_RE = re.compile(r"([A-Za-z_][A-Za-z0-9_]*|max_seq_len|tokens_per_optimizer_step~)=([^,\s]+)")
MEMORY_RE = re.compile(
r"(?:peak|max).*?(?:memory|mem).*?([-+]?\d+(?:\.\d+)?)\s*(GB|GiB|MB|MiB)",
re.IGNORECASE,
)
def to_number(value):
value = str(value).strip().replace(",", "")
if value.lower() in {"true", "false"}:
return value.lower() == "true"
try:
if any(ch in value.lower() for ch in (".", "e")):
return float(value)
return int(value)
except ValueError:
return value
def normalize_key(key):
return key.strip().lower().replace(" ", "_").replace("/", "_").replace("-", "_")
def parse_setup_line(line, setup):
if not line.startswith(" "):
return
stripped = line.strip()
if not stripped or stripped.startswith("="):
return
match = SETUP_KEY_RE.match(line)
value = stripped
if match and "=" not in match.group(1):
name = normalize_key(match.group(1))
value = match.group(2).strip()
setup[name] = value
for segment in re.split(r",\s+", value):
segment = segment.strip()
if "=" in segment:
key, raw_value = segment.split("=", 1)
setup[normalize_key(key)] = to_number(raw_value)
elif "~" in segment:
key, raw_value = segment.split("~", 1)
setup[normalize_key(key)] = to_number(raw_value)
def parse_metric_line(line):
epoch_match = EPOCH_RE.search(line)
if not epoch_match:
return None
record = {k: int(v) for k, v in epoch_match.groupdict().items()}
for key, value in KEY_VALUE_RE.findall(line):
normalized = normalize_key(key)
if normalized.startswith("epoch"):
continue
record[normalized] = to_number(value)
return record
def parse_memory_line(line):
match = MEMORY_RE.search(line)
if not match:
return None
value = float(match.group(1))
unit = match.group(2).lower()
if unit in {"mb", "mib"}:
value /= 1024
return value
def summarize_records(records):
summary = {
"num_records": len(records),
"last_step": None,
"final": {},
"best": {},
"mean": {},
}
if not records:
return summary
last = records[-1]
summary["last_step"] = last.get("step")
numeric_keys = sorted(
{
key
for record in records
for key, value in record.items()
if isinstance(value, (int, float)) and key not in {"epoch", "epochs", "step", "iters"}
}
)
for key in numeric_keys:
values = [record[key] for record in records if isinstance(record.get(key), (int, float))]
if not values:
continue
summary["final"][key] = values[-1]
summary["mean"][key] = mean(values)
if key in {"loss", "logits_loss", "aux_loss", "kl_ref", "epoch_time"}:
summary["best"][key] = min(values)
else:
summary["best"][key] = max(values)
return summary
def parse_log(path, experiment_name=None):
setup = {}
records = []
peak_memory_gb = None
with open(path, "r", encoding="utf-8", errors="ignore") as f:
for line in f:
if "[Training Setup]" in line:
setup["stage"] = line.strip().split("stage=", 1)[-1] if "stage=" in line else line.strip()
continue
parse_setup_line(line, setup)
record = parse_metric_line(line)
if record:
records.append(record)
memory_gb = parse_memory_line(line)
if memory_gb is not None:
peak_memory_gb = memory_gb if peak_memory_gb is None else max(peak_memory_gb, memory_gb)
name = experiment_name or os.path.splitext(os.path.basename(path))[0]
summary = summarize_records(records)
return {
"kind": "train_log_summary",
"experiment_name": name,
"source_log": path,
"setup": setup,
"summary": summary,
"peak_memory_gb": peak_memory_gb,
"records": records,
}
def write_csv(path, parsed):
records = parsed["records"]
if not records:
fieldnames = ["experiment_name"]
else:
fieldnames = ["experiment_name"] + sorted({key for record in records for key in record})
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
with open(path, "w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for record in records:
row = {"experiment_name": parsed["experiment_name"]}
row.update(record)
writer.writerow(row)
def main():
parser = argparse.ArgumentParser(description="Parse SearchLM/SearchShortQA training logs into structured metrics")
parser.add_argument("--log", required=True, help="Training log file")
parser.add_argument("--experiment", default="", help="Experiment name used in JSON/CSV outputs")
parser.add_argument("--output", default="", help="Output summary JSON path")
parser.add_argument("--csv", default="", help="Optional per-log-record CSV path")
parser.add_argument("--drop_records", action="store_true", help="Do not store full metric timeline in JSON")
args = parser.parse_args()
parsed = parse_log(args.log, experiment_name=args.experiment or None)
output_obj = dict(parsed)
if args.drop_records:
output_obj.pop("records", None)
if args.output:
os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True)
with open(args.output, "w", encoding="utf-8") as f:
json.dump(output_obj, f, ensure_ascii=False, indent=2)
else:
print(json.dumps(output_obj, ensure_ascii=False, indent=2))
if args.csv:
write_csv(args.csv, parsed)
if __name__ == "__main__":
main()