packages feed

dtmc-0.3.0.0: benchmark/plot.py

#!/usr/bin/env python3
"""Create runtime and speed-ratio plots from aggregated benchmark CSV files."""

from __future__ import annotations

import argparse
import csv
import statistics
from collections import defaultdict
from pathlib import Path

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt


ROOT = Path(__file__).resolve().parent
RESULTS = ROOT / "results"


def safe_name(value: str) -> str:
    return "".join(character if character.isalnum() else "-" for character in value).strip("-")


def plot_runtime(rows: list[dict[str, str]], plots: Path) -> int:
    grouped: dict[tuple[str, str, str, int], list[float]] = defaultdict(list)
    for row in rows:
        grouped[
            (row["family"], row["operation"], row["engine"], int(row["size"]))
        ].append(float(row["median_seconds"]))

    combinations = sorted({(family, operation) for family, operation, _, _ in grouped})
    for family, operation in combinations:
        fig, axis = plt.subplots(figsize=(7.2, 4.5), constrained_layout=True)
        drawn = False
        for engine, color in (("dtmc", "#3366cc"), ("pydtmc", "#dc3912")):
            points = sorted(
                (
                    size,
                    statistics.median(values),
                )
                for (candidate_family, candidate_operation, candidate_engine, size), values in grouped.items()
                if candidate_family == family
                and candidate_operation == operation
                and candidate_engine == engine
            )
            if points:
                axis.plot(
                    [point[0] for point in points],
                    [point[1] for point in points],
                    marker="o",
                    color=color,
                    label=engine,
                )
                drawn = True
        if not drawn:
            plt.close(fig)
            continue
        axis.set_xscale("log")
        axis.set_yscale("log")
        axis.set_xlabel("states")
        axis.set_ylabel("median seconds")
        axis.set_title(f"{family}: {operation}")
        axis.grid(True, which="both", alpha=0.25)
        axis.legend()
        fig.savefig(plots / f"{safe_name(family)}--{safe_name(operation)}.svg")
        plt.close(fig)
    return len(combinations)


def plot_ratios(rows: list[dict[str, str]], plots: Path) -> int:
    grouped: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
    for row in rows:
        grouped[(row["family"], row["operation"])].append(row)

    for (family, operation), group in sorted(grouped.items()):
        ordered = sorted(group, key=lambda row: int(row["size"]))
        sizes = [int(row["size"]) for row in ordered]
        ratios = [float(row["geometric_median_pydtmc_over_dtmc"]) for row in ordered]
        lows = [float(row["ci95_low"]) for row in ordered]
        highs = [float(row["ci95_high"]) for row in ordered]

        fig, axis = plt.subplots(figsize=(7.2, 4.5), constrained_layout=True)
        axis.plot(sizes, ratios, marker="o", color="#6a3d9a", label="PyDTMC / dtmc")
        axis.fill_between(sizes, lows, highs, color="#6a3d9a", alpha=0.18, label="95% CI")
        axis.axhline(1.0, color="#555555", linestyle="--", linewidth=1.0)
        axis.set_xscale("log")
        axis.set_yscale("log")
        axis.set_xlabel("states")
        axis.set_ylabel("median speed ratio")
        axis.set_title(f"{family}: {operation} speed ratio")
        axis.grid(True, which="both", alpha=0.25)
        axis.legend()
        fig.savefig(
            plots / f"{safe_name(family)}--{safe_name(operation)}--speed-ratio.svg"
        )
        plt.close(fig)
    return len(grouped)


def read_csv(path: Path) -> list[dict[str, str]]:
    with path.open(newline="", encoding="utf-8") as stream:
        return list(csv.DictReader(stream))


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--results-root", type=Path, default=RESULTS)
    args = parser.parse_args()
    results = args.results_root.resolve()

    summary_path = results / "summary.csv"
    ratio_path = results / "ratio-summary.csv"
    if not summary_path.exists() or not ratio_path.exists():
        raise SystemExit("run benchmark/analyse.py before plotting")

    plots = results / "plots"
    plots.mkdir(parents=True, exist_ok=True)
    for stale in plots.glob("*.svg"):
        stale.unlink()
    runtime_count = plot_runtime(read_csv(summary_path), plots)
    ratio_count = plot_ratios(read_csv(ratio_path), plots)
    print(f"wrote {runtime_count} runtime and {ratio_count} ratio plots to {plots}")


if __name__ == "__main__":
    main()