#!/usr/bin/env python3 """ AI 模型基准测试对比图生成器 用法: python benchmark_chart.py -c data.py -o output.png python benchmark_chart.py -c data.py -o output.png --dpi 150 python benchmark_chart.py -c data.yaml -o result.png --width 28 --height 18 数据文件格式(支持 .py / .json / .yaml): TITLE = "总标题" COLORS = {"qwen36": "#5B2D8E", ...} BENCHMARKS = [ { "title": "Benchmark Name", "subtitle": "简短描述", "models": [ {"name": "全名", "short_label": "短标", "group": "moe|dense", "score": 85.0, "color_key": "qwen36"}, ... ], }, ... ] """ import argparse import json import sys from pathlib import Path from typing import Any import matplotlib import matplotlib.pyplot as plt import matplotlib.ticker as mticker import numpy as np matplotlib.use("Agg") # ────────────────────── 默认值 ────────────────────── DEFAULT_COLORS = { "qwen36": "#5B2D8E", "qwen35": "#8B5CF6", "gemma26": "#06B6D4", "qwen27": "#B0B5BD", "gemma31": "#34D399", } DEFAULT_BG = "#E8F5E9" DEFAULT_BAR_WIDTH = 0.55 DEFAULT_DPI = 200 DEFAULT_FIGSIZE = (24, 16) GROUP_GAP = 1.2 # MoE 和 Dense 组间距 def load_data(source: str) -> dict: p = Path(source) if p.suffix == ".py": sys.path.insert(0, str(p.parent)) ns: dict[str, Any] = {} exec(p.read_text(encoding="utf-8"), ns) return ns elif p.suffix == ".json": return json.loads(p.read_text(encoding="utf-8")) elif p.suffix in (".yaml", ".yml"): try: import yaml except ImportError: raise ImportError("需要 PyYAML: pip install pyyaml") return yaml.safe_load(p.read_text(encoding="utf-8")) else: raise ValueError(f"不支持的文件格式: {p.suffix}") def draw_chart(ax: plt.Axes, benchmark: dict, colors: dict, bar_width: float): """在单个 Axes 上绘制一个基准测试的柱状图""" title = benchmark.get("title", "") subtitle = benchmark.get("subtitle", "") models = benchmark.get("models", []) moe_models = [m for m in models if m.get("group", "").lower() == "moe"] dense_models = [m for m in models if m.get("group", "").lower() == "dense"] n_moe = len(moe_models) n_dense = len(dense_models) # ── X 坐标 ── moe_x = np.arange(n_moe) - (n_moe - 1) / 2 if n_moe else np.array([]) dense_start = (n_moe - 1) / 2 + GROUP_GAP + bar_width / 2 if n_moe else 0 dense_x = (np.arange(n_dense) + dense_start) if n_dense else np.array([]) sep_x = (moe_x[-1] + dense_x[0]) / 2 if n_moe > 0 and n_dense > 0 else None # ── 画柱子 ── all_scores: list[float] = [] for i, m in enumerate(moe_models): score = float(m.get("score", 0)) all_scores.append(score) ck = m.get("color_key", "") color = colors.get(ck, "#999") ax.bar(moe_x[i], score, bar_width, color=color, edgecolor="white", linewidth=0.5, zorder=3) for i, m in enumerate(dense_models): score = float(m.get("score", 0)) all_scores.append(score) ck = m.get("color_key", "") color = colors.get(ck, "#999") ax.bar(dense_x[i], score, bar_width, color=color, edgecolor="white", linewidth=0.5, zorder=3) if not all_scores: return y_max = max(all_scores) y_pad = y_max * 0.32 # 上方留白给分数标注 + 分组标签 label_reserve = y_max * 0.22 # 底部留给 x 轴标签的空间 # ═══ X 轴标签:模型短名 ═══ label_fs = 7.5 if n_moe + n_dense > 4 else 8.5 name_y = -label_reserve * 0.4 for i, m in enumerate(moe_models): lbl = m.get("short_label", m.get("name", "")) ax.text(moe_x[i], name_y, lbl, ha="center", va="top", fontsize=label_fs, color="#555", linespacing=1.0) for i, m in enumerate(dense_models): lbl = m.get("short_label", m.get("name", "")) ax.text(dense_x[i], name_y, lbl, ha="center", va="top", fontsize=label_fs, color="#555", linespacing=1.0) # ═══ MoE / Dense 分组标签:放在柱子组上方 ═══ if n_moe > 0: ax.text(moe_x.mean(), y_max + y_pad * 0.55, "MoE", ha="center", va="center", fontsize=8.5, fontweight="bold", color="#888", bbox=dict(boxstyle="round,pad=0.3", fc="white", ec="#ddd", alpha=0.85)) if n_dense > 0: ax.text(dense_x.mean(), y_max + y_pad * 0.55, "Dense", ha="center", va="center", fontsize=8.5, fontweight="bold", color="#888", bbox=dict(boxstyle="round,pad=0.3", fc="white", ec="#ddd", alpha=0.85)) # ── 虚线分隔 ── if sep_x is not None: ax.axvline(x=sep_x, ymin=0.04, ymax=0.92, color="#ccc", linestyle=":", linewidth=1.2, zorder=2) # ── 最高分标注 ── top_score = max(all_scores) for i, m in enumerate(moe_models): if m.get("score", 0) == top_score: ax.annotate( f"{top_score}", xy=(moe_x[i], top_score), xytext=(0, 5), textcoords="offset points", ha="center", va="bottom", fontsize=11, fontweight="bold", color="#222", ) break else: for i, m in enumerate(dense_models): if m.get("score", 0) == top_score: ax.annotate( f"{top_score}", xy=(dense_x[i], top_score), xytext=(0, 5), textcoords="offset points", ha="center", va="bottom", fontsize=11, fontweight="bold", color="#222", ) break # ═══ 标题 + 副标题(放在上方,无重叠) ═══ # 标题:左上角,加粗 ax.text(0.0, 1.02, title, transform=ax.transAxes, fontsize=12, fontweight="bold", color="#222", ha="left", va="bottom") if subtitle: # 副标题:标题下方 ax.text(0.0, 0.94, subtitle, transform=ax.transAxes, fontsize=7.5, color="#aaa", ha="left", va="top", fontstyle="italic") # ── 美化 ── ax.set_facecolor("white") for spine in ("top", "right", "left"): ax.spines[spine].set_visible(False) ax.spines["bottom"].set_color("#ddd") ax.tick_params(axis="x", bottom=False, labelbottom=False) ax.tick_params(axis="y", labelsize=6.5, colors="#aaa", length=0) ax.yaxis.set_major_locator(mticker.MaxNLocator(4, integer=True)) ax.grid(axis="y", color="#f0f0f0", linewidth=0.5, zorder=0) # ── 坐标范围 ── all_x = list(moe_x) + list(dense_x) x_pad = bar_width * 1.8 ax.set_xlim(min(all_x) - x_pad, max(all_x) + x_pad) ax.set_ylim(-label_reserve, y_max + y_pad) def make_chart( data_source: str, output_path: str, dpi: int = DEFAULT_DPI, figsize: tuple = DEFAULT_FIGSIZE, ): data = load_data(data_source) benchmarks = data.get("benchmarks", data.get("BENCHMARKS", [])) colors = data.get("colors", data.get("COLORS", DEFAULT_COLORS)) bg_color = data.get("bg_color", data.get("BG_COLOR", DEFAULT_BG)) bar_width = data.get("bar_width", data.get("BAR_WIDTH", DEFAULT_BAR_WIDTH)) title = data.get("title", data.get("TITLE", "")) ncols = data.get("ncols", data.get("NCOLS", 4)) n = len(benchmarks) if n == 0: print("❌ 没有找到 benchmarks 数据") sys.exit(1) nrows = (n + ncols - 1) // ncols fig, axes = plt.subplots( nrows, ncols, figsize=figsize, facecolor=bg_color, ) fig.subplots_adjust( hspace=0.55, wspace=0.35, top=0.93, bottom=0.09, left=0.04, right=0.96, ) # 统一 axes 数组 axes_flat = np.array(axes).flatten() if title: fig.suptitle(title, fontsize=16, fontweight="bold", color="#222", y=0.97) for i, bench in enumerate(benchmarks): draw_chart(axes_flat[i], bench, colors, bar_width) for j in range(i + 1, len(axes_flat)): axes_flat[j].set_visible(False) # ── 底部图例 ── seen: dict[str, dict] = {} for bench in benchmarks: for m in bench.get("models", []): name = m.get("name", "") if name not in seen: seen[name] = m if seen: handles = [] labels = [] for name, m in seen.items(): ck = m.get("color_key", "") color = colors.get(ck, "#999") handles.append(plt.Rectangle((0, 0), 1, 1, fc=color, ec="white", lw=0.5)) labels.append(name) fig.legend( handles, labels, loc="lower center", ncol=min(len(seen), 5), frameon=False, fontsize=8, bbox_to_anchor=(0.5, 0.005), ) fig.savefig(output_path, dpi=dpi, bbox_inches="tight", facecolor=bg_color) print(f"✅ 图表已保存到 {output_path}") return output_path def main(): parser = argparse.ArgumentParser(description="AI Benchmark 对比图生成器") parser.add_argument("-c", "--config", required=True, help="数据配置文件 (.py / .json / .yaml)") parser.add_argument("-o", "--output", default="benchmark_chart.png", help="输出图片路径") parser.add_argument("--dpi", type=int, default=DEFAULT_DPI, help=f"分辨率 (默认: {DEFAULT_DPI})") parser.add_argument("--width", type=float, default=DEFAULT_FIGSIZE[0], help="图宽 (英寸)") parser.add_argument("--height", type=float, default=DEFAULT_FIGSIZE[1], help="图高 (英寸)") args = parser.parse_args() make_chart( data_source=args.config, output_path=args.output, dpi=args.dpi, figsize=(args.width, args.height), ) if __name__ == "__main__": main()