173 lines
6.5 KiB
Python
173 lines
6.5 KiB
Python
"""Charts. One colour per model, fixed by its position in MODELS."""
|
|||
|
|
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import matplotlib
|
||
|
|
|
||
|
|
matplotlib.use("Agg")
|
||
|
|
|
||
|
|
import matplotlib.pyplot as plt # noqa: E402
|
||
|
|
import numpy as np # noqa: E402
|
||
|
|
import pandas as pd # noqa: E402
|
||
|
|
from matplotlib.ticker import FuncFormatter, LogLocator, PercentFormatter # noqa: E402
|
||
|
|
|
||
|
|
from .evaluate import COVERAGES, horizon_label # noqa: E402
|
||
|
|
from .forecast import Forecast # noqa: E402
|
||
|
|
from .halving import CYCLE_STARTS # noqa: E402
|
||
|
|
from .models import BASELINE, MODELS # noqa: E402
|
||
|
|
|
||
|
|
SURFACE = "#fcfcfb"
|
||
|
|
INK = "#0b0b0b"
|
||
|
|
INK_SECONDARY = "#52514e"
|
||
|
|
MUTED = "#898781"
|
||
|
|
GRID = "#e1e0d9"
|
||
|
|
AXIS = "#c3c2b7"
|
||
|
|
SERIES = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#4a3aa7", "#e34948"]
|
||
|
|
|
||
|
|
plt.rcParams.update(
|
||
|
|
{
|
||
|
|
"figure.facecolor": SURFACE,
|
||
|
|
"axes.facecolor": SURFACE,
|
||
|
|
"savefig.facecolor": SURFACE,
|
||
|
|
"axes.edgecolor": AXIS,
|
||
|
|
"axes.linewidth": 0.8,
|
||
|
|
"axes.labelcolor": INK_SECONDARY,
|
||
|
|
"axes.titlecolor": INK,
|
||
|
|
"axes.titlesize": 11,
|
||
|
|
"axes.titleweight": "semibold",
|
||
|
|
"axes.titlelocation": "left",
|
||
|
|
"axes.spines.top": False,
|
||
|
|
"axes.spines.right": False,
|
||
|
|
"axes.grid": True,
|
||
|
|
"grid.color": GRID,
|
||
|
|
"grid.linewidth": 0.8,
|
||
|
|
"xtick.color": MUTED,
|
||
|
|
"ytick.color": MUTED,
|
||
|
|
"xtick.labelcolor": INK_SECONDARY,
|
||
|
|
"ytick.labelcolor": INK_SECONDARY,
|
||
|
|
"legend.frameon": False,
|
||
|
|
"legend.labelcolor": INK_SECONDARY,
|
||
|
|
"lines.linewidth": 2,
|
||
|
|
"lines.solid_capstyle": "round",
|
||
|
|
"lines.solid_joinstyle": "round",
|
||
|
|
"font.family": "sans-serif",
|
||
|
|
"font.size": 9,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def model_color(name: str) -> str:
|
||
|
|
return SERIES[list(MODELS).index(name) % len(SERIES)]
|
||
|
|
|
||
|
|
|
||
|
|
def price_formatter(x, _=None) -> str:
|
||
|
|
for scale, suffix in ((1e9, "B"), (1e6, "M"), (1e3, "K")):
|
||
|
|
if x >= scale:
|
||
|
|
return f"${x / scale:.3g}{suffix}"
|
||
|
|
return f"${x:.3g}"
|
||
|
|
|
||
|
|
|
||
|
|
def fan_chart(history: pd.DataFrame, forecasts: dict[str, Forecast], path: Path) -> None:
|
||
|
|
"""History plus 50/80/95% intervals, one panel per model on shared axes."""
|
||
|
|
fig, axes = plt.subplots(
|
||
|
|
len(forecasts), 1, figsize=(10, 3.2 * len(forecasts)), sharex=True, sharey=True
|
||
|
|
)
|
||
|
|
axes = np.atleast_1d(axes)
|
||
|
|
shown = history[history.index >= history.index[-1] - pd.Timedelta(days=6 * 365)]
|
||
|
|
for ax, (name, f) in zip(axes, forecasts.items(), strict=True):
|
||
|
|
color = model_color(name)
|
||
|
|
for c in sorted(COVERAGES, reverse=True):
|
||
|
|
lo, hi = f.interval(c)
|
||
|
|
ax.fill_between(f.dates, np.exp(lo), np.exp(hi), color=color, alpha=0.1, lw=0)
|
||
|
|
median = np.exp(f.quantile(0.5))
|
||
|
|
ax.plot(shown.index, shown["close"], color=INK, lw=1.2)
|
||
|
|
ax.plot(f.dates, median, color=color)
|
||
|
|
ax.annotate(
|
||
|
|
f"median {price_formatter(median[-1])}",
|
||
|
|
(f.dates[-1], median[-1]),
|
||
|
|
xytext=(6, 0),
|
||
|
|
textcoords="offset points",
|
||
|
|
va="center",
|
||
|
|
color=INK_SECONDARY,
|
||
|
|
)
|
||
|
|
for start in CYCLE_STARTS:
|
||
|
|
if shown.index[0] <= start <= f.dates[-1]:
|
||
|
|
ax.axvline(start, color=AXIS, lw=0.8, zorder=0)
|
||
|
|
ax.set_yscale("log")
|
||
|
|
ax.yaxis.set_major_locator(LogLocator(base=10, subs=(1, 2, 5)))
|
||
|
|
ax.yaxis.set_major_formatter(FuncFormatter(price_formatter))
|
||
|
|
ax.yaxis.set_minor_formatter(FuncFormatter(lambda *_: ""))
|
||
|
|
ax.set_title(f"{name}: forecast from {f.origin:%Y-%m-%d}")
|
||
|
|
axes[0].fill_between([], [], color=MUTED, alpha=0.3, label="50% interval")
|
||
|
|
axes[0].fill_between([], [], color=MUTED, alpha=0.2, label="80% interval")
|
||
|
|
axes[0].fill_between([], [], color=MUTED, alpha=0.1, label="95% interval")
|
||
|
|
axes[0].legend(loc="upper left")
|
||
|
|
axes[-1].set_xlabel("vertical lines: halvings (future ones projected)", color=MUTED)
|
||
|
|
fig.tight_layout()
|
||
|
|
fig.savefig(path, dpi=150)
|
||
|
|
plt.close(fig)
|
||
|
|
|
||
|
|
|
||
|
|
def skill_chart(summary: pd.DataFrame, path: Path, title: str) -> None:
|
||
|
|
"""CRPS skill vs the random walk, by horizon, with bootstrap intervals."""
|
||
|
|
horizons = sorted(summary["horizon"].unique())
|
||
|
|
x = np.arange(len(horizons))
|
||
|
|
fig, ax = plt.subplots(figsize=(8, 4.5))
|
||
|
|
ax.axhline(0, color=model_color(BASELINE), lw=2, label=BASELINE)
|
||
|
|
for name, g in summary[summary.model != BASELINE].groupby("model", sort=False):
|
||
|
|
g = g.set_index("horizon").reindex(horizons)
|
||
|
|
color = model_color(name)
|
||
|
|
ax.fill_between(x, g.skill_lo, g.skill_hi, color=color, alpha=0.1, lw=0)
|
||
|
|
ax.plot(x, g.skill, color=color, marker="o", ms=6, mec=SURFACE, mew=2, label=name)
|
||
|
|
ax.annotate(
|
||
|
|
name,
|
||
|
|
(x[-1], g.skill.iloc[-1]),
|
||
|
|
xytext=(8, 0),
|
||
|
|
textcoords="offset points",
|
||
|
|
va="center",
|
||
|
|
color=INK_SECONDARY,
|
||
|
|
)
|
||
|
|
ax.set_xticks(x, [horizon_label(h) for h in horizons])
|
||
|
|
ax.set_xlabel("forecast horizon")
|
||
|
|
ax.set_ylabel("CRPS skill vs random walk (higher is better)")
|
||
|
|
ax.yaxis.set_major_formatter(PercentFormatter(1.0, decimals=0))
|
||
|
|
ax.set_title(title)
|
||
|
|
ax.legend(loc="lower left")
|
||
|
|
fig.tight_layout()
|
||
|
|
fig.savefig(path, dpi=150)
|
||
|
|
plt.close(fig)
|
||
|
|
|
||
|
|
|
||
|
|
def calibration_chart(summary: pd.DataFrame, path: Path, title: str) -> None:
|
||
|
|
"""How often each nominal interval contained the outcome, by horizon."""
|
||
|
|
horizons = sorted(summary["horizon"].unique())
|
||
|
|
x = np.arange(len(horizons))
|
||
|
|
fig, axes = plt.subplots(1, len(COVERAGES), figsize=(12, 4), sharey=True)
|
||
|
|
for ax, c in zip(axes, COVERAGES, strict=True):
|
||
|
|
ax.axhline(c, color=INK_SECONDARY, lw=1)
|
||
|
|
ax.annotate(
|
||
|
|
"target", (x[-1], c), xytext=(0, 4), textcoords="offset points", ha="right", color=MUTED
|
||
|
|
)
|
||
|
|
for name, g in summary.groupby("model", sort=False):
|
||
|
|
g = g.set_index("horizon").reindex(horizons)
|
||
|
|
ax.plot(
|
||
|
|
x,
|
||
|
|
g[f"cov{c:.0%}"],
|
||
|
|
color=model_color(name),
|
||
|
|
marker="o",
|
||
|
|
ms=6,
|
||
|
|
mec=SURFACE,
|
||
|
|
mew=2,
|
||
|
|
label=name,
|
||
|
|
)
|
||
|
|
ax.set_xticks(x, [horizon_label(h) for h in horizons])
|
||
|
|
ax.set_title(f"{c:.0%} interval")
|
||
|
|
ax.set_ylim(0, 1.02)
|
||
|
|
ax.yaxis.set_major_formatter(PercentFormatter(1.0, decimals=0))
|
||
|
|
axes[0].set_ylabel("share of outcomes inside")
|
||
|
|
axes[-1].legend(loc="lower left")
|
||
|
|
fig.suptitle(title, x=0.01, ha="left", color=INK, fontsize=11, fontweight="semibold")
|
||
|
|
fig.tight_layout()
|
||
|
|
fig.savefig(path, dpi=150)
|
||
|
|
plt.close(fig)
|