Skip to content

T08. Turn study results into a report

Goal

Create tables, plots, and a bounded interpretation from saved observations; trace a reported group back to its runs; and separate descriptive summaries from inferential claims.

Before you start

Item This lesson
Prerequisites T07 measurements, or the downloadable recorded observations
Tested environment VAMOS 1.0.1, MooCore 0.3.2, Matplotlib 3.10.9; setup
Interface status Experimental vamos.ux.api.compute_ranks and plot_hv_convergence; ordinary Python CSV/JSON and Matplotlib
Work Recompute/validate metrics from retained fronts and render; no optimization executes

Define the report before reading a winner

Use one row per problem/algorithm/budget group and retain all stochastic repeats. The report shows the median and Q1/Q3 of HV and IGD+ with their directions. Quartiles use NumPy's explicit linear interpolation rule. They summarize the sample's variability; they are not confidence intervals or a statistical test.

The per-run CSV keeps the task, selected attempt, run ID, manifest path/hash, seed, budget and state. It lets a reader trace a table cell to each contributing observation. The original canonical study remains the authority for execution and recovery.

Complete code

Download t08-study-report.py. Report the small workflow into a new directory:

python t08-study-report.py --measurements results/tutorial-metrics/measurements.json --output results/tutorial-report
Complete runnable script
"""T08: generated from examples/tutorials/study_workflow.py; see study-setup for dependencies."""


def metric_policy():
    return {
        "objectives": "minimize both; original units; no per-run normalization",
        "hv_reference": [1.1, 11.0],
        "igd_plus_reference": "201 analytical points, f1 uniformly spaced on [0,1]",
        "zdt1_f2": "1 - sqrt(f1)",
        "zdt2_f2": "1 - f1**2",
        "aggregation": "median and linear-interpolation quartiles within problem/algorithm/budget",
        "budget_axis": "separate fresh studies; not checkpoints or a continuous trajectory",
    }


def measure_front(problem, front):
    import numpy as np
    from moocore import hypervolume, igd_plus

    values = np.asarray(front, dtype=float)
    if values.ndim != 2 or values.shape[1] != 2 or not len(values) or not np.isfinite(values).all():
        raise ValueError("Expected a nonempty finite two-objective front.")
    if problem not in {"zdt1", "zdt2"}:
        raise ValueError("This tutorial defines references only for zdt1 and zdt2.")
    reference_point = np.array(metric_policy()["hv_reference"])
    if np.any(values >= reference_point):
        raise ValueError("A point lies outside the declared HV reference box; review the protocol.")
    x = np.linspace(0.0, 1.0, 201)
    y = 1.0 - np.sqrt(x) if problem == "zdt1" else 1.0 - x**2
    reference_front = np.column_stack([x, y])
    return {
        "hv": float(hypervolume(values, ref=reference_point)),
        "igd_plus": float(igd_plus(values, ref=reference_front)),
    }


def summarize_measurements(dataset):
    import hashlib
    import itertools
    import json

    import numpy as np

    from vamos.ux.api import compute_ranks

    if dataset.get("schema") != "vamos.documentation.study-measurements.v1" or dataset["policy"] != metric_policy():
        raise ValueError("Unsupported tutorial measurement schema or policy.")
    rows = dataset["rows"]
    expected = set()
    for study in dataset["studies"]:
        matrix = study["intent"]["matrix"]
        if study["state"] != "completed" or study["issues"]:
            raise ValueError("Resolve incomplete studies or inspection issues before reporting.")
        for problem, algorithm, seed in itertools.product(matrix["problems"], matrix["algorithms"], matrix["seeds"]):
            expected.add((study["study_id"], problem, algorithm, seed, study["budget"]))
    keys = [(r["study_id"], r["problem"], r["algorithm"], r["seed"], r["evaluation_budget"]) for r in rows]
    if not expected or len(keys) != len(set(keys)) or set(keys) != expected:
        raise ValueError("Missing or duplicate planned observations; do not silently change the sample.")
    for row in rows:
        if row["state"] != "succeeded" or row["evaluations"] != row["evaluation_budget"]:
            raise ValueError("Resolve failed/incomplete runs or unequal evaluation budgets before reporting.")
        front_hash = hashlib.sha256(json.dumps(row["front"], separators=(",", ":")).encode()).hexdigest()
        if front_hash != row["front_sha256"]:
            raise ValueError("The retained objective front has changed.")
        actual = measure_front(row["problem"], row["front"])
        if any(not np.isfinite(row[name]) or not np.isclose(row[name], actual[name], rtol=1e-12, atol=1e-12) for name in actual):
            raise ValueError("A metric does not match its retained objective front.")
    groups = []
    for key in sorted({(r["problem"], r["algorithm"], r["evaluation_budget"]) for r in rows}):
        selected = [r for r in rows if (r["problem"], r["algorithm"], r["evaluation_budget"]) == key]
        group = dict(zip(("problem", "algorithm", "budget"), key))
        group["n"] = len(selected)
        for metric in ("hv", "igd_plus"):
            q1, median, q3 = np.quantile([r[metric] for r in selected], [0.25, 0.5, 0.75], method="linear")
            group.update({f"{metric}_q1": float(q1), f"{metric}_median": float(median), f"{metric}_q3": float(q3)})
        groups.append(group)
    final_budget = max(g["budget"] for g in groups)
    problems, algorithms = sorted({g["problem"] for g in groups}), sorted({g["algorithm"] for g in groups})
    matrix = np.array(
        [
            [
                next(g["hv_median"] for g in groups if (g["problem"], g["algorithm"], g["budget"]) == (p, a, final_budget))
                for a in algorithms
            ]
            for p in problems
        ]
    )
    ranks = compute_ranks(matrix, higher_is_better=True)
    return {
        "groups": groups,
        "final_budget": final_budget,
        "problems": problems,
        "algorithms": algorithms,
        "scores": matrix.tolist(),
        "ranks": ranks.tolist(),
    }


def save_tutorial_figure(fig, path):
    fig.savefig(path, metadata={"Date": None}, bbox_inches="tight")
    svg = path.read_text(encoding="utf-8")
    path.write_text("\n".join(line.rstrip() for line in svg.splitlines()) + "\n", encoding="utf-8")


def draw_study_figures(dataset, summary, output):
    import matplotlib

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

    from vamos.ux.api import plot_hv_convergence

    with plt.rc_context({"svg.hashsalt": "vamos-study-tutorials", "svg.fonttype": "none", "font.size": 10}):
        fig, axes = plt.subplots(1, len(summary["problems"]), figsize=(10, 4), squeeze=False, layout="constrained")
        for ax, problem in zip(axes[0], summary["problems"]):
            for algorithm in summary["algorithms"]:
                groups = sorted(
                    (g for g in summary["groups"] if g["problem"] == problem and g["algorithm"] == algorithm), key=lambda g: g["budget"]
                )
                budgets = np.array([g["budget"] for g in groups])
                plot_hv_convergence(budgets, np.array([g["hv_median"] for g in groups]), ax=ax, label=algorithm, title=problem.upper())
                ax.lines[-1].set_marker("o")
                if len(groups) == 1:
                    g = groups[0]
                    ax.errorbar(
                        budgets,
                        [g["hv_median"]],
                        yerr=[[g["hv_median"] - g["hv_q1"]], [g["hv_q3"] - g["hv_median"]]],
                        fmt="none",
                        color=ax.lines[-1].get_color(),
                        capsize=4,
                    )
                ax.fill_between(budgets, [g["hv_q1"] for g in groups], [g["hv_q3"] for g in groups], alpha=0.15)
            ax.set_xlabel("Evaluation budget per fresh run")
            ax.set_ylabel("Hypervolume (higher is better)")
            ax.grid(alpha=0.2)
        fig.suptitle("Median and IQR across seeds · separate runs, not a continuous trajectory")
        save_tutorial_figure(fig, output / "budget-comparison.svg")
        plt.close(fig)
        fig, axes = plt.subplots(1, len(summary["problems"]), figsize=(10, 4), squeeze=False, layout="constrained")
        for ax, problem in zip(axes[0], summary["problems"]):
            values = [
                [
                    r["igd_plus"]
                    for r in dataset["rows"]
                    if r["problem"] == problem and r["algorithm"] == a and r["evaluation_budget"] == summary["final_budget"]
                ]
                for a in summary["algorithms"]
            ]
            ax.boxplot(values, tick_labels=summary["algorithms"], whis=(0, 100), showfliers=False)
            for position, sample in enumerate(values, 1):
                ax.scatter(np.full(len(sample), position), sample, s=16, c="#007f7a", alpha=0.65, zorder=3)
            ax.set(title=problem.upper(), ylabel="IGD+ (lower is better)")
            ax.grid(axis="y", alpha=0.2)
        fig.suptitle(f"All retained repeats at {summary['final_budget']} evaluations · whiskers show min/max")
        save_tutorial_figure(fig, output / "final-distributions.svg")
        plt.close(fig)


def render_report(measurements, output):
    import csv
    import hashlib
    import json
    from pathlib import Path

    output = Path(output)
    if output.exists():
        raise FileExistsError(f"Choose a new report directory: {output}")
    source = Path(measurements)
    dataset = json.loads(source.read_text(encoding="utf-8"))
    summary = summarize_measurements(dataset)
    output.mkdir(parents=True, exist_ok=False)
    with (output / "metrics.csv").open("w", newline="", encoding="utf-8") as handle:
        fields = [name for name in dataset["rows"][0] if name not in {"front", "failure"}]
        writer = csv.DictWriter(handle, fieldnames=fields, extrasaction="ignore", lineterminator="\n")
        writer.writeheader()
        writer.writerows(dataset["rows"])
    with (output / "summary.csv").open("w", newline="", encoding="utf-8") as handle:
        writer = csv.DictWriter(handle, fieldnames=list(summary["groups"][0]), lineterminator="\n")
        writer.writeheader()
        writer.writerows(summary["groups"])
    lines = [
        "# Descriptive VAMOS study report",
        "",
        f"Source SHA-256: `{hashlib.sha256(source.read_bytes()).hexdigest()}`",
        "",
        "All observations come from verified persisted runs. This report does not rerun optimization.",
        "",
        "| Problem | Algorithm | Budget | Repeats | HV median [Q1, Q3] ↑ | IGD+ median [Q1, Q3] ↓ |",
        "| --- | --- | ---: | ---: | --- | --- |",
    ]
    for g in summary["groups"]:
        lines.append(
            f"| {g['problem']} | {g['algorithm']} | {g['budget']} | {g['n']} | "
            f"{g['hv_median']:.6f} [{g['hv_q1']:.6f}, {g['hv_q3']:.6f}] | "
            f"{g['igd_plus_median']:.6f} [{g['igd_plus_q1']:.6f}, {g['igd_plus_q3']:.6f}] |"
        )
    lines += ["", "## What these observations show", ""]
    for problem in summary["problems"]:
        final = [g for g in summary["groups"] if g["problem"] == problem and g["budget"] == summary["final_budget"]]
        comparisons = "; ".join(
            f"{g['algorithm']}: median HV {g['hv_median']:.6f}, median IGD+ {g['igd_plus_median']:.6f} ({g['n']} repeats)" for g in final
        )
        lines += [f"At {summary['final_budget']} evaluations on **{problem}**, {comparisons}.", ""]
    lines += [
        "Read higher HV and lower IGD+ as better under the declared references. Compare the spreads in the table and plots,",
        "then limit any preference to these problems, settings, budgets and observed repeats; it is not evidence of general superiority.",
    ]
    lines += [
        "",
        "![Budget comparison](budget-comparison.svg)",
        "",
        "![Final distributions](final-distributions.svg)",
        "",
        "## Interpretation limits",
        "",
        "These are per-problem descriptive observations, not a general algorithm ranking.",
        "Quartiles describe variability; they are not confidence intervals. Shared seed labels do not create paired samples.",
        "The budget comparisons use fresh runs and charge every run in full; no continuous convergence history was recorded.",
        "Only two problems are included. No p-value, significance claim, or algorithm-wide superiority is reported.",
        "Do not average raw HV across different problems. See metrics.csv for each task, selected attempt, run ID and manifest hash.",
        "",
    ]
    (output / "report.md").write_text("\n".join(lines), encoding="utf-8")
    (output / "descriptive-ranks.json").write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
    draw_study_figures(dataset, summary, output)
    print(f"Report observations: {len(dataset['rows'])}")
    print(f"Problem/algorithm/budget groups: {len(summary['groups'])}")
    print(f"Descriptive rank matrix: {len(summary['problems'])} problems x {len(summary['algorithms'])} algorithms")
    print("No inferential test: two problems do not support a general superiority claim.")
    return summary


def main_t08(argv=None):
    import argparse

    parser = argparse.ArgumentParser(description="Create a descriptive report from retained, measured observations.")
    parser.add_argument("--measurements", required=True)
    parser.add_argument("--output", required=True)
    args = parser.parse_args(argv)
    render_report(args.measurements, args.output)


if __name__ == "__main__":
    main_t08()

Observed result

Actual output of that eight-task workflow:

Report observations: 8
Problem/algorithm/budget groups: 4
Descriptive rank matrix: 2 problems x 2 algorithms
No inferential test: two problems do not support a general superiority claim.

The directory contains report.md, metrics.csv, summary.csv, two SVG figures, and descriptive-ranks.json. The report records the SHA-256 of its input observations. It refuses missing/duplicate planned observations, incomplete studies, altered retained fronts, or metrics that no longer match those fronts. No failure is dropped or assigned a favorable score to make a table complete.

Rebuild the recorded report

Download recorded-measurements.json and run:

python t08-study-report.py --measurements recorded-measurements.json --output results/retained-report

This uses 120 real observations from the larger recorded protocol, not the eight-row smoke example or a hand-written score matrix. View the complete generated report, per-run metrics, and group summaries.

Actual IGD+ distributions for every retained seed at 2,000 evaluations, separated by problem; whiskers span minimum to maximum.

Every dot is one retained repeat. The box shows Q1/median/Q3; whiskers span minimum to maximum. Read each problem panel separately. A distribution visible on these two benchmarks does not establish a framework-wide ranking.

From numbers to justified conclusions

A defensible sentence names the problem, configuration, budget, indicator, replication and observed variability. For example, describe a lower observed median IGD+ at a stated budget, then inspect all repeats and the other indicator. Do not turn that statement into “algorithm A is universally better”.

The experimental compute_ranks helper receives a matrix with one row per problem and one column per algorithm; each cell is the predeclared median HV at the final budget. Seeds stay inside their cell. These ranks are descriptive; they are not a significance result, and raw HV is not averaged across problems.

There are only two problems and two algorithms here, so this report performs no Friedman or Wilcoxon test and draws no critical-distance inference. For an adequately designed larger comparison, the statistical guide describes the existing helpers, matrix orientation, metric direction, and the fact that pairwise p-values are raw and require an explicit multiplicity procedure when appropriate. Identical seed labels alone never justify treating runs as paired.

Try one change

Make a copy of the downloaded observations and remove one row. Report that copy into a new directory. The command must fail with a missing-observations error; it must not silently report a smaller sample. Keep the original data intact. For a changed scientific design, create a new T06 plan and regenerate its observations instead of editing numerical results.

Next step

You now have the full chain from planned tasks to measured observations and a reviewable report. Use Run artifacts & replay to verify/reproduce individual saved runs, and the analysis guide when your experiment supports a broader statistical procedure. Return to Tutorials for other learning paths.