327 lines
14 KiB
Python
327 lines
14 KiB
Python
"""Build a single-file interactive Optuna dashboard (中文 HTML).
|
||||
|
|
|
|||
|
|
Loads the persisted Optuna study from ``studies/optuna/gold_scalper_pro_is2025.db`` and
|
|||
|
|
writes ``reports/optuna_dashboard_<study>.html`` containing 7 plotly charts
|
|||
|
|
bundled into one page (each ``fig.to_html(full_html=False, include_plotlyjs='cdn')``
|
|||
|
|
fragments + minimal CSS). Every chart's title and axis labels are localized
|
|||
|
|
to 简体中文 so the report reads natively.
|
|||
|
|
|
|||
|
|
Charts:
|
|||
|
|
1. 优化历史 (plot_optimization_history)
|
|||
|
|
2. 参数重要性 (plot_param_importances)
|
|||
|
|
3. 平行坐标图 (plot_parallel_coordinate)
|
|||
|
|
4. 参数切片图 (plot_slice)
|
|||
|
|
5. 等高线图 (plot_contour)
|
|||
|
|
6. 经验分布函数 (plot_edf)
|
|||
|
|
7. 时间线 (plot_timeline)
|
|||
|
|
|
|||
|
|
Usage:
|
|||
|
|
python scripts/build_optuna_dashboard.py
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import sys
|
|||
|
|
from pathlib import Path
|
|||
|
|
from html import escape
|
|||
|
|
|
|||
|
|
PROJECT = Path(__file__).resolve().parent.parent
|
|||
|
|
sys.path.insert(0, str(PROJECT))
|
|||
|
|
|
|||
|
|
import optuna
|
|||
|
|
|
|||
|
|
STUDY_NAME = "gold_scalper_pro_is2025"
|
|||
|
|
STUDY_DB = PROJECT / "studies" / "optuna" / "gold_scalper_pro_is2025.db"
|
|||
|
|
OUT_HTML = PROJECT / "reports" / f"optuna_dashboard_{STUDY_NAME}.html"
|
|||
|
|
|
|||
|
|
# Chart-level metadata: (call name, 中文标题, 中文 X 轴, 中文 Y 轴)
|
|||
|
|
# Y axis label None means "leave Optuna default" (some plots set their own).
|
|||
|
|
# Note: plot_slice and plot_contour are rendered separately as per-param
|
|||
|
|
# grids below the main dashboard — those two are too dense (19 params) to
|
|||
|
|
# be readable as a single chart.
|
|||
|
|
CHARTS: list[tuple[str, str, str | None, str | None]] = [
|
|||
|
|
("plot_optimization_history", "优化历史",
|
|||
|
|
"试验序号 Trial", "目标值 Objective (score)"),
|
|||
|
|
("plot_param_importances", "参数重要性 (fANOVA)",
|
|||
|
|
"超参数 Hyperparameter", "重要性 Importance"),
|
|||
|
|
("plot_parallel_coordinate", "平行坐标图 — 参数 ↔ score",
|
|||
|
|
None, None),
|
|||
|
|
("plot_edf", "经验分布函数 (EDF)",
|
|||
|
|
"目标值 Objective", "累积分布 CDF"),
|
|||
|
|
("plot_timeline", "时间线 — 试验耗时与状态",
|
|||
|
|
"试验序号 Trial", "耗时 (秒) Elapsed (s)"),
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
# Per-parameter charts: each param gets its own small slice + contour grid.
|
|||
|
|
# Rendered as separate <section> blocks below the main dashboard.
|
|||
|
|
PER_PARAM_CHARTS = [
|
|||
|
|
"InpFastEmaPeriod", "InpSlowEmaPeriod", "InpRsiPeriod",
|
|||
|
|
"InpRsiBuyLevel", "InpRsiSellLevel", "InpPullbackAtrMult",
|
|||
|
|
"InpAtrPeriod", "InpMaxSpreadAtrPct",
|
|||
|
|
"InpRiskPercent", "InpAtrSLMult", "InpAtrTPMult",
|
|||
|
|
"InpBreakEvenPoints", "InpBreakEvenLock",
|
|||
|
|
"InpTrailStartPoints", "InpTrailStepPoints",
|
|||
|
|
"InpMaxTradesPerDay", "InpDailyLossLimit", "InpMinSecondsBetween",
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def localize(fig, title_zh: str, x_zh: str | None, y_zh: str | None):
|
|||
|
|
"""Localize a plotly Figure's title + axis labels to 简体中文."""
|
|||
|
|
fig.update_layout(title=title_zh)
|
|||
|
|
if x_zh is not None:
|
|||
|
|
fig.update_xaxes(title_text=x_zh)
|
|||
|
|
if y_zh is not None:
|
|||
|
|
fig.update_yaxes(title_text=y_zh)
|
|||
|
|
# Translate the legend "Objective" → "目标值" where it shows up.
|
|||
|
|
if fig.layout.legend and fig.layout.legend.title:
|
|||
|
|
leg = fig.layout.legend.title.text
|
|||
|
|
if leg and "Objective" in leg:
|
|||
|
|
fig.update_layout(legend_title_text="图例")
|
|||
|
|
# Apply a Chinese-readable base font + light theme.
|
|||
|
|
fig.update_layout(
|
|||
|
|
font=dict(family="Microsoft YaHei, Arial, sans-serif", size=12, color="#222"),
|
|||
|
|
template="plotly_white",
|
|||
|
|
)
|
|||
|
|
return fig
|
|||
|
|
|
|||
|
|
|
|||
|
|
def render_chart(fn_name: str, study: optuna.Study, include_plotly: bool) -> str:
|
|||
|
|
"""Call optuna.visualization.<fn>(study), localize, return HTML fragment.
|
|||
|
|
|
|||
|
|
plotly.js is loaded ONCE via CDN <script> in ``<head>`` (see
|
|||
|
|
build_index_html). Every chart fragment therefore passes
|
|||
|
|
include_plotlyjs=False — no per-chart JS bundle, no async race, no 5 MB
|
|||
|
|
of inline JS blocking the parser before any chart can render.
|
|||
|
|
"""
|
|||
|
|
fn = getattr(optuna.visualization, fn_name, None)
|
|||
|
|
if fn is None:
|
|||
|
|
return f'<div class="chart-error">⚠ 函数 <code>{escape(fn_name)}</code> 不存在</div>'
|
|||
|
|
try:
|
|||
|
|
fig = fn(study)
|
|||
|
|
except Exception as e: # some plots fail on trivial studies
|
|||
|
|
return (f'<div class="chart-error">⚠ <code>{escape(fn_name)}</code> 生成失败:'
|
|||
|
|
f'{escape(str(e))}</div>')
|
|||
|
|
title_zh = next(t for fn_, t, *_ in CHARTS if fn_ == fn_name)
|
|||
|
|
x_zh = next(x for fn_, _, x, *_ in CHARTS if fn_ == fn_name)
|
|||
|
|
y_zh = next(y for fn_, _, _, y in CHARTS if fn_ == fn_name)
|
|||
|
|
fig = localize(fig, title_zh, x_zh, y_zh)
|
|||
|
|
fig.update_layout(height=520, width=1100)
|
|||
|
|
return fig.to_html(
|
|||
|
|
full_html=False,
|
|||
|
|
include_plotlyjs=False,
|
|||
|
|
div_id=f"chart-{fn_name}",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def render_slice_grid(study: optuna.Study, params: list[str]) -> list[tuple[str, str]]:
|
|||
|
|
"""Render one slice chart PER parameter — readable single-column subplots.
|
|||
|
|
|
|||
|
|
plot_slice(study) defaults to cramming all params into one 5400px-wide
|
|||
|
|
figure where axis labels overlap. Splitting per-param gives each a
|
|||
|
|
1100×520 card where labels are readable.
|
|||
|
|
"""
|
|||
|
|
fragments: list[tuple[str, str]] = []
|
|||
|
|
for p in params:
|
|||
|
|
try:
|
|||
|
|
fig = optuna.visualization.plot_slice(study, params=[p])
|
|||
|
|
except Exception as e:
|
|||
|
|
frag = (f'<div class="chart-error">⚠ slice[{escape(p)}] 生成失败:'
|
|||
|
|
f'{escape(str(e))}</div>')
|
|||
|
|
fragments.append((f"切片 — {p}", frag))
|
|||
|
|
continue
|
|||
|
|
fig = localize(fig, f"参数切片 — {p}", p, "目标值 Objective (score)")
|
|||
|
|
fig.update_layout(height=420, width=900, margin=dict(l=60, r=40, t=60, b=60))
|
|||
|
|
frag = fig.to_html(full_html=False, include_plotlyjs=False,
|
|||
|
|
div_id=f"slice-{p}")
|
|||
|
|
fragments.append((f"切片 — {p}", frag))
|
|||
|
|
return fragments
|
|||
|
|
|
|||
|
|
|
|||
|
|
def render_contour_grid(study: optuna.Study, params: list[str]) -> list[tuple[str, str]]:
|
|||
|
|
"""Render contour charts for the most important param pairs.
|
|||
|
|
|
|||
|
|
Full N×N contour is unreadable (19² = 361 subplots). Instead, take the
|
|||
|
|
top-K most important params (by fANOVA) and render only those pairs —
|
|||
|
|
a K×K grid that's actually readable.
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
importances = optuna.importance.get_param_importances(study)
|
|||
|
|
# Get top-K by importance; only params that exist in our list.
|
|||
|
|
top_params = [p for p, _ in sorted(importances.items(),
|
|||
|
|
key=lambda x: x[1], reverse=True)
|
|||
|
|
if p in params][:6]
|
|||
|
|
except Exception:
|
|||
|
|
top_params = params[:6] # fallback: first 6
|
|||
|
|
|
|||
|
|
fragments: list[tuple[str, str]] = []
|
|||
|
|
# Render each pair (i<j) as its own contour chart.
|
|||
|
|
for i, p1 in enumerate(top_params):
|
|||
|
|
for p2 in top_params[i + 1:]:
|
|||
|
|
try:
|
|||
|
|
fig = optuna.visualization.plot_contour(study, params=[p1, p2])
|
|||
|
|
except Exception as e:
|
|||
|
|
frag = (f'<div class="chart-error">⚠ contour[{escape(p1)}×{escape(p2)}] '
|
|||
|
|
f'生成失败:{escape(str(e))}</div>')
|
|||
|
|
fragments.append((f"等高线 — {p1} × {p2}", frag))
|
|||
|
|
continue
|
|||
|
|
fig = localize(fig, f"等高线 — {p1} × {p2}", p1, p2)
|
|||
|
|
fig.update_layout(height=520, width=700,
|
|||
|
|
margin=dict(l=70, r=70, t=60, b=70))
|
|||
|
|
frag = fig.to_html(full_html=False, include_plotlyjs=False,
|
|||
|
|
div_id=f"contour-{p1}-{p2}")
|
|||
|
|
fragments.append((f"等高线 — {p1} × {p2}", frag))
|
|||
|
|
return fragments
|
|||
|
|
|
|||
|
|
|
|||
|
|
def build_index_html(
|
|||
|
|
study: optuna.Study,
|
|||
|
|
main_fragments: list[tuple[str, str]],
|
|||
|
|
slice_fragments: list[tuple[str, str]],
|
|||
|
|
contour_fragments: list[tuple[str, str]],
|
|||
|
|
) -> str:
|
|||
|
|
"""Assemble chart fragments into a single styled dashboard HTML."""
|
|||
|
|
n_trials = len(study.trials)
|
|||
|
|
completed = len([t for t in study.trials if t.state == optuna.trial.TrialState.COMPLETE])
|
|||
|
|
pruned = len([t for t in study.trials if t.state == optuna.trial.TrialState.PRUNED])
|
|||
|
|
failed = len([t for t in study.trials if t.state == optuna.trial.TrialState.FAIL])
|
|||
|
|
best = study.best_trial if study.best_trial is not None else None
|
|||
|
|
best_str = (
|
|||
|
|
f"trial #{best.number}, score={best.value:.4f}"
|
|||
|
|
if best is not None else "—"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def cards(frs):
|
|||
|
|
return "\n".join(
|
|||
|
|
f'<section class="chart"><h2>{escape(title)}</h2>{frag}</section>'
|
|||
|
|
for title, frag in frs
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
main_cards = cards(main_fragments)
|
|||
|
|
slice_cards = cards(slice_fragments)
|
|||
|
|
contour_cards = cards(contour_fragments)
|
|||
|
|
|
|||
|
|
slice_section = (
|
|||
|
|
f'<h2 class="section-title">参数切片图(单参数影响)</h2>'
|
|||
|
|
f'<p class="section-desc">每参数独立小图,避免 19 参数挤在 5400px 宽的复合图里导致标签重叠。</p>'
|
|||
|
|
f'{slice_cards}'
|
|||
|
|
if slice_fragments else ""
|
|||
|
|
)
|
|||
|
|
contour_section = (
|
|||
|
|
f'<h2 class="section-title">等高线图(参数两两交互)</h2>'
|
|||
|
|
f'<p class="section-desc">按 fANOVA 重要性 Top-6 参数两两配对,避免 19²=361 子图密集到无法读。</p>'
|
|||
|
|
f'{contour_cards}'
|
|||
|
|
if contour_fragments else ""
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return f"""<!DOCTYPE html>
|
|||
|
|
<html lang="zh-CN">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="utf-8">
|
|||
|
|
<title>Optuna Dashboard — {escape(STUDY_NAME)}</title>
|
|||
|
|
<!-- plotly.js loaded via CDN, SYNCHRONOUSLY in <head> (no async/defer).
|
|||
|
|
Browser blocks parsing until this <script> finishes, so by the time
|
|||
|
|
the body's chart <script>Plotly.newPlot(...)</script> tags execute,
|
|||
|
|
window.Plotly is defined. -->
|
|||
|
|
<script src="https://cdn.plot.ly/plotly-3.6.0.min.js"></script>
|
|||
|
|
<style>
|
|||
|
|
:root {{
|
|||
|
|
--bg:#f5f5f7; --fg:#222; --card:#fff; --border:#ddd;
|
|||
|
|
--accent:#2563eb; --muted:#666;
|
|||
|
|
}}
|
|||
|
|
* {{ box-sizing: border-box; }}
|
|||
|
|
body {{
|
|||
|
|
margin: 0; padding: 2rem; background: var(--bg); color: var(--fg);
|
|||
|
|
font-family: "Microsoft YaHei", "Segoe UI", Arial, sans-serif; line-height: 1.6;
|
|||
|
|
}}
|
|||
|
|
header {{ margin-bottom: 2rem; border-bottom: 2px solid var(--accent); padding-bottom: 1rem; }}
|
|||
|
|
h1 {{ margin: 0 0 .25rem; font-size: 1.75rem; }}
|
|||
|
|
h2 {{ margin: 0 0 .75rem; font-size: 1.25rem; color: var(--accent); }}
|
|||
|
|
.meta {{ display: flex; gap: 1.5rem; flex-wrap: wrap; color: var(--muted); font-size: .9rem; }}
|
|||
|
|
.meta b {{ color: var(--fg); }}
|
|||
|
|
.grid {{ display: flex; flex-direction: column; gap: 2rem; }}
|
|||
|
|
.chart {{
|
|||
|
|
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
|
|||
|
|
padding: 1.5rem; box-shadow: 0 1px 3px rgba(0,0,0,.04);
|
|||
|
|
overflow-x: auto;
|
|||
|
|
}}
|
|||
|
|
.chart-error {{ color: #b00; padding: 1rem; background: #fff0f0; border-radius: 6px; }}
|
|||
|
|
.section-title {{
|
|||
|
|
margin: 3rem 0 0.5rem; padding-top: 1.5rem; border-top: 2px dashed var(--accent);
|
|||
|
|
font-size: 1.4rem; color: var(--accent);
|
|||
|
|
}}
|
|||
|
|
.section-desc {{ margin: 0 0 1.5rem; color: var(--muted); font-size: .9rem; }}
|
|||
|
|
footer {{ margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--border);
|
|||
|
|
color: var(--muted); font-size: .85rem; }}
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<header>
|
|||
|
|
<h1>Optuna 优化仪表盘</h1>
|
|||
|
|
<div class="meta">
|
|||
|
|
<span>研究名称:<b>{escape(STUDY_NAME)}</b></span>
|
|||
|
|
<span>试验总数:<b>{n_trials}</b></span>
|
|||
|
|
<span>完成:<b>{completed}</b></span>
|
|||
|
|
<span>剪枝:<b>{pruned}</b></span>
|
|||
|
|
<span>失败:<b>{failed}</b></span>
|
|||
|
|
<span>最佳:<b>{best_str}</b></span>
|
|||
|
|
</div>
|
|||
|
|
</header>
|
|||
|
|
<main class="grid">
|
|||
|
|
{main_cards}
|
|||
|
|
{slice_section}
|
|||
|
|
{contour_section}
|
|||
|
|
</main>
|
|||
|
|
<footer>
|
|||
|
|
生成时间:2026-06-26 · 来源:<code>{escape(str(STUDY_DB.relative_to(PROJECT)))}</code>
|
|||
|
|
· 框架:<a href="https://optuna.org">Optuna</a> + <a href="https://plotly.com/python">Plotly</a>
|
|||
|
|
</footer>
|
|||
|
|
</body>
|
|||
|
|
</html>
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main() -> int:
|
|||
|
|
if not STUDY_DB.exists():
|
|||
|
|
print(f"study DB not found: {STUDY_DB}")
|
|||
|
|
return 1
|
|||
|
|
print(f"loading study: {STUDY_NAME} ← {STUDY_DB.relative_to(PROJECT)}")
|
|||
|
|
study = optuna.load_study(
|
|||
|
|
study_name=STUDY_NAME,
|
|||
|
|
storage=f"sqlite:///{STUDY_DB}",
|
|||
|
|
)
|
|||
|
|
best_val = study.best_trial.value if study.best_trial is not None else None
|
|||
|
|
print(f" trials: {len(study.trials)} best: "
|
|||
|
|
f"{best_val:.4f}" if best_val is not None else " trials: (no best yet)")
|
|||
|
|
|
|||
|
|
# Main dashboard charts (single-figure plots that render fine at 1100×520).
|
|||
|
|
main_fragments: list[tuple[str, str]] = []
|
|||
|
|
for i, (fn_name, title_zh, *_) in enumerate(CHARTS):
|
|||
|
|
print(f" · {fn_name} ({title_zh}) …", end=" ", flush=True)
|
|||
|
|
frag = render_chart(fn_name, study, include_plotly=(i == 0))
|
|||
|
|
main_fragments.append((title_zh, frag))
|
|||
|
|
print("OK" if "chart-error" not in frag else "FAILED")
|
|||
|
|
|
|||
|
|
# Per-parameter slice charts — readable single-column subplots instead
|
|||
|
|
# of plot_slice's 5400px-wide composite that crammed all 19 params.
|
|||
|
|
print(f"\n building per-param slice charts ({len(PER_PARAM_CHARTS)} params)…")
|
|||
|
|
slice_fragments = render_slice_grid(study, PER_PARAM_CHARTS)
|
|||
|
|
n_ok = sum(1 for _, f in slice_fragments if "chart-error" not in f)
|
|||
|
|
print(f" slice: {n_ok}/{len(slice_fragments)} OK")
|
|||
|
|
|
|||
|
|
# Per-pair contour charts — only top-6 important params (15 pairs)
|
|||
|
|
# instead of plot_contour's 19² = 361 unreadable subplots.
|
|||
|
|
print(f" building per-pair contour charts (top-6 important params)…")
|
|||
|
|
contour_fragments = render_contour_grid(study, PER_PARAM_CHARTS)
|
|||
|
|
n_ok = sum(1 for _, f in contour_fragments if "chart-error" not in f)
|
|||
|
|
print(f" contour: {n_ok}/{len(contour_fragments)} OK")
|
|||
|
|
|
|||
|
|
OUT_HTML.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
html = build_index_html(study, main_fragments, slice_fragments, contour_fragments)
|
|||
|
|
OUT_HTML.write_text(html, encoding="utf-8")
|
|||
|
|
print(f"\nwritten: {OUT_HTML.relative_to(PROJECT)} ({len(html):,} bytes)")
|
|||
|
|
print(f"open: {OUT_HTML.as_uri()}")
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
raise SystemExit(main())
|