diff options
Diffstat (limited to 'experiments/plot_plain_cnn_pareto.py')
| -rw-r--r-- | experiments/plot_plain_cnn_pareto.py | 486 |
1 files changed, 486 insertions, 0 deletions
diff --git a/experiments/plot_plain_cnn_pareto.py b/experiments/plot_plain_cnn_pareto.py new file mode 100644 index 0000000..5702f2b --- /dev/null +++ b/experiments/plot_plain_cnn_pareto.py @@ -0,0 +1,486 @@ +#!/usr/bin/env python3 +"""Render the audited 27-cell Plain-CNN accuracy/time Pareto figure.""" +import argparse +import hashlib +import json +import math +import os + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +from matplotlib.lines import Line2D +from matplotlib.ticker import FuncFormatter, FixedLocator + + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +METHODS = ( + "bp", "fa", "dfa", "pepita", "ff", "ep", "dualprop", + "clean_kp", "sdil", +) +ARCHITECTURES = ("minicnn", "vgglike", "vgg16") +LABELS = { + "bp": "BP", + "fa": "FA", + "dfa": "DFA", + "pepita": "PEPITA", + "ff": "Forward-Forward", + "ep": "EP", + "dualprop": "Dual Prop", + "clean_kp": "clean KP", + "sdil": "SDIL", +} +ARCH_LABELS = { + "minicnn": "miniCNN · 3 trainable layers", + "vgglike": "VGGlike · 5 trainable layers", + "vgg16": "VGG16 · 16 trainable layers", +} +COLORS = { + "bp": "#222222", + "fa": "#009E73", + "dfa": "#E69F00", + "pepita": "#8C8C8C", + "ff": "#56B4E9", + "ep": "#CC79A7", + "dualprop": "#7B61A8", + "clean_kp": "#2E8B57", + "sdil": "#D55E00", +} +MARKERS = { + "bp": "*", + "fa": "s", + "dfa": "^", + "pepita": "v", + "ff": "P", + "ep": "D", + "dualprop": "h", + "clean_kp": "X", + "sdil": "o", +} +PDF_METADATA = { + "Creator": "SDIL audited crossover figure pipeline", + "Producer": "Matplotlib", + "CreationDate": None, + "ModDate": None, +} + + +def sha256(path): + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def read_json(path): + with open(path, encoding="utf-8") as handle: + return json.load(handle) + + +def finite(record): + accuracy = record.get("best_validation_accuracy") + wall = record.get("driver_wall_seconds") + return ( + record.get("status") == "completed" + and record.get("outcome") == "finite" + and isinstance(accuracy, (int, float)) + and math.isfinite(float(accuracy)) + and isinstance(wall, (int, float)) + and math.isfinite(float(wall)) + and wall > 0 + ) + + +def point(record): + return ( + float(record["driver_wall_seconds"]) / 3600.0, + float(record["best_validation_accuracy"]), + ) + + +def pareto(records): + candidates = [record for record in records if finite(record)] + frontier = [] + for record in candidates: + wall, accuracy = point(record) + dominated = any( + other is not record + and point(other)[0] <= wall + and point(other)[1] >= accuracy + and ( + point(other)[0] < wall + or point(other)[1] > accuracy + ) + for other in candidates + ) + if not dominated: + frontier.append(record) + return sorted(frontier, key=lambda row: point(row)[0]) + + +def validate(report): + assert report["gate"] == "pass" + assert report["stage"] == "plain_cnn_p2" + assert report["num_expected_records"] == 27 + assert report["num_audited_records"] == 27 + assert report["missing_experiments"] == [] + assert report["test_policy"] == "none" + records = report["records"] + assert len(records) == 27 + actual = { + (record["architecture"], record["method"]) for record in records + } + expected = { + (architecture, method) + for architecture in ARCHITECTURES for method in METHODS + } + assert actual == expected + for record in records: + wall = record.get("driver_wall_seconds") + accuracy = record.get("best_validation_accuracy") + assert isinstance(wall, (int, float)) and wall > 0 + assert ( + isinstance(accuracy, (int, float)) + and 0 <= accuracy <= 100 + ) + return records + + +def frontier_ids(records, include_bp): + eligible = [ + record for record in records + if include_bp or record["method"] != "bp" + ] + return [ + f"{record['architecture']}::{record['method']}" + for record in pareto(eligible) + ] + + +def render(records, outdir): + plt.rcParams.update({ + "font.family": "DejaVu Sans", + "font.size": 8.2, + "axes.labelsize": 8.5, + "axes.titlesize": 10.2, + "legend.fontsize": 7.4, + "xtick.labelsize": 7.4, + "ytick.labelsize": 7.4, + "axes.spines.top": False, + "axes.spines.right": False, + "savefig.bbox": "tight", + }) + figure, axes = plt.subplots( + 1, 3, figsize=(11.5, 4.15), sharey=True, + gridspec_kw={"wspace": 0.12}, + ) + panels = {} + for panel_index, (axis, architecture) in enumerate( + zip(axes, ARCHITECTURES) + ): + subset = [ + record for record in records + if record["architecture"] == architecture + ] + local_frontier = pareto([ + record for record in subset if record["method"] != "bp" + ]) + overall_frontier = pareto(subset) + panels[architecture] = { + "local_frontier": [ + f"{architecture}::{record['method']}" + for record in local_frontier + ], + "overall_frontier": [ + f"{architecture}::{record['method']}" + for record in overall_frontier + ], + } + axis.plot( + [point(record)[0] for record in local_frontier], + [point(record)[1] for record in local_frontier], + color="#6B8E9B", + linewidth=2.1, + solid_capstyle="round", + zorder=1, + ) + axis.plot( + [point(record)[0] for record in overall_frontier], + [point(record)[1] for record in overall_frontier], + color="#222222", + linewidth=1.3, + linestyle=(0, (3, 2)), + zorder=2, + ) + for record in subset: + wall, accuracy = point(record) + method = record["method"] + is_finite = finite(record) + marker = MARKERS[method] if is_finite else "x" + size = 93 if method == "sdil" else (78 if method == "bp" else 56) + linewidth = 1.8 if method == "sdil" else 1.0 + scatter_style = { + "s": size, + "marker": marker, + "color": COLORS[method], + "linewidth": linewidth, + "alpha": 1.0 if is_finite else 0.82, + "zorder": 5 if method == "sdil" else 4, + } + if marker != "x": + scatter_style["edgecolor"] = "white" + axis.scatter( + wall, + accuracy, + **scatter_style, + ) + if method == "sdil": + axis.annotate( + "SDIL", + (wall, accuracy), + xytext=(5, -11 if architecture == "vgg16" else 6), + textcoords="offset points", + color=COLORS["sdil"], + fontweight="bold", + fontsize=7.5, + zorder=7, + ) + local_ids = panels[architecture]["local_frontier"] + sdil_is_frontier = f"{architecture}::sdil" in local_ids + badge = ( + "SDIL on local frontier" + if sdil_is_frontier + else "clean KP dominates SDIL" + ) + badge_color = "#E8F3F7" if sdil_is_frontier else "#F8E9E4" + axis.text( + 0.035, + 0.96, + badge, + transform=axis.transAxes, + ha="left", + va="top", + fontsize=7.2, + fontweight="bold", + color="#33444A" if sdil_is_frontier else "#8A3A22", + bbox={ + "boxstyle": "round,pad=0.28", + "facecolor": badge_color, + "edgecolor": "none", + }, + ) + axis.axhline( + 10, + color="#A0A0A0", + linewidth=0.75, + linestyle=":", + zorder=0, + ) + if panel_index == 0: + axis.text( + 0.035, + 0.125, + "chance", + transform=axis.transAxes, + fontsize=6.8, + color="#777777", + ) + axis.set_xscale("log") + axis.xaxis.set_major_locator( + FixedLocator([0.01, 0.03, 0.1, 0.3, 1, 3, 10]) + ) + axis.xaxis.set_major_formatter(FuncFormatter( + lambda value, _: f"{value:g}" + )) + axis.set_xlim(0.0075, 9.2) + axis.set_ylim(0, 100) + axis.set_xlabel("Measured training wall time (hours, log scale)") + axis.set_title( + f"{chr(97 + panel_index)} {ARCH_LABELS[architecture]}", + loc="left", + fontweight="bold", + ) + axis.grid( + True, + which="major", + color="#D9D9D9", + linewidth=0.55, + alpha=0.68, + zorder=0, + ) + axis.grid( + True, + which="minor", + axis="x", + color="#EEEEEE", + linewidth=0.4, + alpha=0.55, + zorder=0, + ) + axes[0].set_ylabel("Best validation accuracy (%)") + + method_handles = [ + Line2D( + [0], [0], + marker=MARKERS[method], + linestyle="none", + markerfacecolor=COLORS[method], + markeredgecolor="white", + markeredgewidth=0.7, + markersize=6.8 if method != "sdil" else 7.8, + label=LABELS[method], + ) + for method in METHODS + ] + line_handles = [ + Line2D( + [0], [0], + color="#6B8E9B", + linewidth=2.1, + label="local-method frontier (BP excluded)", + ), + Line2D( + [0], [0], + color="#222222", + linewidth=1.3, + linestyle=(0, (3, 2)), + label="overall frontier (BP included)", + ), + Line2D( + [0], [0], + marker="x", + color="#666666", + linestyle="none", + markersize=6, + label="nonfinite trajectory", + ), + ] + figure.legend( + handles=method_handles + line_handles, + loc="upper center", + bbox_to_anchor=(0.5, 1.02), + ncol=6, + frameon=False, + handlelength=2.2, + columnspacing=1.25, + ) + figure.suptitle( + "Matched local learning: accuracy–time Pareto frontiers", + x=0.5, + y=1.15, + fontsize=13.0, + fontweight="bold", + ) + figure.text( + 0.5, + 1.075, + "Complete 27/27 validation panel · CIFAR-10 · seed 0 · " + "single-GPU GTX 1080 timing", + ha="center", + fontsize=8.2, + color="#555555", + ) + os.makedirs(outdir, exist_ok=True) + pdf_path = os.path.join(outdir, "figure7_plain_cnn_pareto.pdf") + png_path = os.path.join(outdir, "figure7_plain_cnn_pareto.png") + figure.savefig(pdf_path, metadata=PDF_METADATA) + figure.savefig(png_path, dpi=320) + plt.close(figure) + return pdf_path, png_path, panels + + +def write_caption(path): + caption = ( + "**Figure 7: Complete matched Plain-CNN accuracy–time crossover.** " + "Best CIFAR-10 validation accuracy is plotted against measured " + "single-GPU GTX-1080 training wall time for every registered method " + "at miniCNN, VGGlike, and VGG16. Solid lines are empirical Pareto " + "frontiers among non-backpropagation methods; dashed lines include " + "BP as an optimization reference. Crosses retain nonfinite " + "trajectories at their last finite validation metric. SDIL lies on " + "the local-method frontier for miniCNN and VGGlike, while clean KP " + "slightly dominates it at VGG16. Dual Propagation remains more " + "accurate than SDIL at VGG16 but requires substantially more wall " + "time. The figure supports local-method scaling and cost " + "competitiveness, not global dominance over BP." + ) + with open(path, "w", encoding="utf-8") as handle: + handle.write(caption + "\n") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "--audit", + default=os.path.join(ROOT, "results", "plain_cnn_p2_audit.json"), + ) + parser.add_argument( + "--outdir", + default=os.path.join(ROOT, "results", "figs"), + ) + args = parser.parse_args() + report = read_json(args.audit) + records = validate(report) + pdf_path, png_path, panels = render(records, args.outdir) + + expected_sdil_frontier = { + "minicnn": True, + "vgglike": True, + "vgg16": False, + } + observed = { + architecture: + f"{architecture}::sdil" + in panels[architecture]["local_frontier"] + for architecture in ARCHITECTURES + } + assert observed == expected_sdil_frontier + assert "vgg16::clean_kp" in panels["vgg16"]["local_frontier"] + + caption_path = os.path.join( + args.outdir, "figure7_plain_cnn_pareto_caption.md" + ) + write_caption(caption_path) + script_path = os.path.abspath(__file__) + manifest = { + "audit_status": "passed", + "figure": "figure7_plain_cnn_pareto", + "source": { + "audit_path": os.path.relpath( + os.path.abspath(args.audit), ROOT + ), + "audit_sha256": sha256(args.audit), + "script_path": os.path.relpath(script_path, ROOT), + "script_sha256": sha256(script_path), + }, + "num_expected_cells": 27, + "num_audited_cells": len(records), + "metric": "best_validation_accuracy", + "cost": "driver_wall_seconds", + "local_frontier_excludes": ["bp"], + "panels": panels, + "sdil_on_local_frontier": observed, + "outputs": { + os.path.basename(pdf_path): sha256(pdf_path), + os.path.basename(png_path): sha256(png_path), + os.path.basename(caption_path): sha256(caption_path), + }, + } + manifest_path = os.path.join( + args.outdir, "figure7_plain_cnn_pareto_manifest.json" + ) + with open(manifest_path, "w", encoding="utf-8") as handle: + json.dump(manifest, handle, indent=2, sort_keys=True) + handle.write("\n") + print(json.dumps({ + "audit_status": "passed", + "num_audited_cells": len(records), + "sdil_on_local_frontier": observed, + "png": png_path, + "pdf": pdf_path, + }, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() |
