京都の桜の満開日を 1212 年分にわたって記録したデータがあります。
このデータは、大阪公立大学(旧大阪府立大学)の 青野靖之 教授と 川合慶子 氏が、平安時代の『日本後紀』(812 年)から現代までの史料を読み解き、年ごとの満開日として整理したものです(Aono & Kazui, International Journal of Climatology, 2008)。
このデータが 2021 年、「r/dataisbeautiful」に投稿されて 15,000 upvote を超える反応を集めました。投稿者の散布図と移動平均線が、文字通り「桜の枝」のように見えたのも大きな理由です。
このデータを題材に、seaborn と matplotlib で美しいグラフを描く実践例をまとめます。配色、マーカー形状、注釈の配置、移動平均、Warming Stripes、ヒストグラムの重ね合わせなど、他の題材にも転用しやすい手法を中心に扱います。
本稿で用いるのはブラウザ上で動く Python 環境 fudebako です。インストール不要、データはローカル完結。matplotlib の日本語ラベルが標準で表示されます。
データの読み込み
データは bmait101/data-viz の KyotoFullFlowerW.xls(青野・川合 2008 のオリジナル Excel のミラー)を CSV 化し、GitHub Gist で公開 しました。
ブラウザ上の Python は標準ライブラリの urllib が https を扱えないため、pd.read_csv(url) を直接呼ぶ代わりに Pyodide 同梱の pyfetch で本文を取得し、StringIO 経由で pd.read_csv に渡します:
import pandas as pd
import matplotlib.pyplot as plt
from io import StringIO
from pyodide.http import pyfetch
URL = "https://gist.githubusercontent.com/yonaka15/90ac7f0043565265efd4f78b526bf18d/raw/kyoto_cherry.csv"
r = await pyfetch(URL)
text = await r.string()
df = pd.read_csv(StringIO(text))
print(df.head(3))
print("AD range:", df["AD"].min(), "-", df["AD"].max())
AD DOY FlowerDate SourceCode ReferenceName
0 812 92 401 1.0 NIHON-KOKI
1 815 105 415 1.0 NIHON-KOKI
2 831 96 406 1.0 NIHON-KOKI
AD range: 812 - 2010
最古の記録は『日本後紀』、812 年の DOY 92(4 月 1 日)です。
URL からの取得が面倒なら、CSV をローカルに落として fudebako の Drive タブにドラッグ & ドロップし、pd.read_csv("kyoto_cherry.csv") で読む方法もあります。こちらは外部通信が一切発生しません。
§1: 散布図 + 30 年移動平均で「桜の枝」を描く
まずは配色の定義から。DOY (Day of Year) を実日付に変換するヘルパも添えておきます。
SAKURA = "#FFB7C5"
HIGHLIGHT = "#FF6B9D"
BRANCH = "#6B4423"
SKY = "#E8F4F8"
def doy_to_date(doy):
return (pd.Timestamp(2024, 1, 1) + pd.Timedelta(days=int(doy)-1)).strftime("%-m/%-d")
fig, ax = plt.subplots(figsize=(12, 6.5), facecolor=SKY)
ax.set_facecolor(SKY)
ma = df.set_index("AD").sort_index()["DOY"].rolling(window=30, min_periods=10).mean()
ax.plot(ma.index, ma.values, color=BRANCH, linewidth=4.5, solid_capstyle="round",
label="30年移動平均", zorder=1, alpha=0.92)
ax.scatter(df["AD"], df["DOY"], s=24, alpha=0.5, c=SAKURA, marker="h",
edgecolor="white", linewidths=0.2, label="満開日(観測)", zorder=2)
yticks = [85, 95, 105, 115, 125]
ax.set_yticks(yticks)
ax.set_yticklabels([doy_to_date(d) for d in yticks])
ax.set_xlabel("年 (AD)", fontsize=12, color=BRANCH)
ax.set_ylabel("満開日", fontsize=12, color=BRANCH)
ax.set_title("京都の桜の満開日(812-2010)",
loc="left", pad=15, fontsize=15, color=BRANCH, fontweight="black")
ax.set_xlim(800, 2025)
ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.10), ncol=1, frameon=False, fontsize=11)
ax.invert_yaxis()
ax.grid(alpha=0.15, color=BRANCH)
ax.spines[["top", "right"]].set_visible(False)
ax.spines[["bottom", "left"]].set_color(BRANCH)
ax.tick_params(colors=BRANCH)
plt.tight_layout()
plt.show()
invert_yaxis() で Y 軸を反転させ、早い満開日が上に来るようにしています。
§2: ヒストグラムを重ねて「分布のズレ」を可視化
ヒストグラムは2 群の分布比較に有効です。1500–1900 年と 1971–2010 年の満開日を、半透明の重ねヒストグラムで描きます。
medieval = df[(df["AD"] >= 1500) & (df["AD"] < 1900)]["DOY"].dropna()
modern = df[df["AD"] >= 1971]["DOY"].dropna()
bins = range(82, 130, 2)
fig, ax = plt.subplots(figsize=(11, 6), facecolor=SKY)
ax.set_facecolor(SKY)
ax.hist(medieval, bins=bins, color=BRANCH, alpha=0.45,
label="1500–1900年代 (n=" + str(len(medieval)) + ")",
edgecolor="white", linewidth=0.6)
ax.hist(modern, bins=bins, color=HIGHLIGHT, alpha=0.65,
label="1971–2010年代 (n=" + str(len(modern)) + ")",
edgecolor="white", linewidth=0.6)
med_med = medieval.median()
mod_med = modern.median()
ax.axvline(med_med, color=BRANCH, linestyle="--", linewidth=2, alpha=0.8)
ax.axvline(mod_med, color=HIGHLIGHT, linestyle="--", linewidth=2, alpha=0.9)
y_arrow = ax.get_ylim()[1] * 0.85
ax.annotate("", xy=(mod_med, y_arrow), xytext=(med_med, y_arrow),
arrowprops=dict(arrowstyle="->", color=BRANCH, lw=2.5))
ax.text((med_med + mod_med)/2, y_arrow*1.05, str(round(med_med - mod_med, 1)) + " 日",
ha="center", fontsize=15, fontweight="bold", color=BRANCH)
xticks = [85, 95, 105, 115, 125]
ax.set_xticks(xticks)
ax.set_xticklabels([doy_to_date(d) for d in xticks])
ax.set_xlabel("満開日", fontsize=12, color=BRANCH)
ax.set_ylabel("年数(観測回数)", fontsize=12, color=BRANCH)
ax.set_title("中世(1500–1900)と現代(1971–2010)の分布",
loc="left", pad=15, fontsize=15, color=BRANCH, fontweight="black")
ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.10), ncol=1, frameon=False, fontsize=11)
ax.spines[["top", "right"]].set_visible(False)
ax.spines[["bottom", "left"]].set_color(BRANCH)
ax.tick_params(colors=BRANCH)
ax.grid(axis="y", alpha=0.2, color=BRANCH)
plt.tight_layout()
plt.show()
中央値の縦点線が、約 9 日離れています。
§3: seaborn.violinplot で時代別の分布を並べる
歴史的な時代区分(平安・鎌倉・室町・戦国-江戸・明治-現代)でカテゴリを切り、seaborn.violinplot の inner="quartile" オプションで四分位を内蔵させます。
import micropip
await micropip.install("seaborn")
import seaborn as sns
def era(year):
if year < 1185: return "平安"
if year < 1336: return "鎌倉"
if year < 1573: return "室町"
if year < 1868: return "戦国・江戸"
return "明治-現代"
df["era"] = df["AD"].apply(era)
era_order = ["平安", "鎌倉", "室町", "戦国・江戸", "明治-現代"]
palette = [SAKURA, SAKURA, SAKURA, SAKURA, HIGHLIGHT]
fig, ax = plt.subplots(figsize=(12, 6), facecolor=SKY)
ax.set_facecolor(SKY)
sns.violinplot(data=df, x="era", y="DOY", order=era_order, palette=palette,
hue="era", legend=False, ax=ax, inner="quartile", linewidth=1.5)
# 明治-現代のエッジを太くする
for i, coll in enumerate(ax.collections):
if i == len(era_order) - 1:
coll.set_edgecolor(HIGHLIGHT)
coll.set_linewidth(2.5)
yticks = [85, 95, 105, 115, 125]
ax.set_yticks(yticks)
ax.set_yticklabels([doy_to_date(d) for d in yticks])
ax.set_xlabel("時代", fontsize=12, color=BRANCH)
ax.set_ylabel("満開日", fontsize=12, color=BRANCH)
ax.set_title("時代別の満開日分布",
loc="left", pad=15, fontsize=15, color=BRANCH, fontweight="black")
ax.invert_yaxis()
ax.grid(axis="y", alpha=0.15, color=BRANCH)
ax.spines[["top", "right"]].set_visible(False)
ax.spines[["bottom", "left"]].set_color(BRANCH)
ax.tick_params(colors=BRANCH)
for i, e in enumerate(era_order):
sub = df[df["era"] == e].dropna(subset=["DOY"])
n = len(sub)
ax.text(i, 80, "n=" + str(n), ha="center", fontsize=10, color=BRANCH, alpha=0.7)
plt.tight_layout()
plt.show()
seaborn は fudebako で micropip.install("seaborn") で取得できます。
§4: imshow + LinearSegmentedColormap で Warming Stripes
Ed Hawkins 氏が考案した「Warming Stripes」を桜カラーで描きます。LinearSegmentedColormap.from_list で独自カラーマップを作り、imshow に 1 行の配列を渡すだけで縞模様になります。
import numpy as np
from matplotlib.colors import LinearSegmentedColormap
years_full_idx = pd.RangeIndex(start=int(df["AD"].min()), stop=int(df["AD"].max())+1, name="AD")
doys_smooth_s = df.set_index("AD")["DOY"].reindex(years_full_idx).interpolate(limit_direction="both")
ma_s = doys_smooth_s.rolling(window=30, min_periods=10, center=True).mean().bfill().ffill()
years_s = ma_s.index.values
cmap = LinearSegmentedColormap.from_list("sakura_warming", ["#FF6B9D", "#FFB7C5", "#E8F4F8"])
vmin_p, vmax_p = np.nanpercentile(ma_s.values, [5, 95])
fig, ax = plt.subplots(figsize=(14, 3), facecolor=SKY)
arr = ma_s.values[None, :]
ax.imshow(arr, aspect="auto", cmap=cmap,
extent=[int(years_s.min()), int(years_s.max()), 0, 1],
vmin=vmin_p, vmax=vmax_p)
ax.set_yticks([])
ax.set_xlabel("年 (AD)", fontsize=12, color=BRANCH)
ax.set_title("1212 年分の 30 年移動平均(早い年は濃ピンク、遅い年は水色)",
loc="left", pad=10, fontsize=14, color=BRANCH, fontweight="black")
ax.spines[["top", "right", "left"]].set_visible(False)
ax.spines["bottom"].set_color(BRANCH)
ax.tick_params(colors=BRANCH)
plt.tight_layout()
plt.show()
np.nanpercentile で色のスケールを 5–95 パーセンタイルに収め、外れ値に引きずられないようにしています。
§5: 部分集合のハイライト + arc3 注釈
散布図に戻り、直近 30 年(1981-2010)を別レイヤーで強調します。アノテーションは arc3 で曲線の引き出し線、bbox で白い背景つきラベルにすると、データ密度の高い領域でも読みやすくなります。
recent_30 = df[(df["AD"] >= 1981) & (df["AD"] <= 2010)].copy()
all_min_row = df.nsmallest(1, "DOY").iloc[0]
am_year = int(all_min_row["AD"])
am_doy = int(all_min_row["DOY"])
am_date = (pd.Timestamp(2024, 1, 1) + pd.Timedelta(days=am_doy-1)).strftime("%-m月%-d日")
top1 = recent_30.nsmallest(1, "DOY").iloc[0]
ty = int(top1["AD"])
td = int(top1["DOY"])
tdate = (pd.Timestamp(2024, 1, 1) + pd.Timedelta(days=td-1)).strftime("%-m月%-d日")
fig, ax = plt.subplots(figsize=(13, 7), facecolor=SKY)
ax.set_facecolor(SKY)
ma = df.set_index("AD").sort_index()["DOY"].rolling(window=30, min_periods=10).mean()
ax.plot(ma.index, ma.values, color=BRANCH, linewidth=3.5, solid_capstyle="round",
label="30年移動平均", zorder=1, alpha=0.92)
ax.scatter(df["AD"], df["DOY"], s=22, alpha=0.35, c=SAKURA, marker="h",
edgecolor="white", linewidths=0.2, label="満開日(全観測)", zorder=2)
ax.scatter(recent_30["AD"], recent_30["DOY"], s=80, alpha=0.85, c=HIGHLIGHT, marker="h",
edgecolor="white", linewidth=0.8, label="直近30年(1981-2010)", zorder=3)
ax.annotate(str(ty) + "年 " + tdate,
xy=(ty, td), xytext=(ty-280, td-7),
fontsize=12, color=BRANCH, fontweight="bold",
arrowprops=dict(arrowstyle="->", connectionstyle="arc3,rad=0.25",
color=BRANCH, lw=1.5),
bbox=dict(boxstyle="round,pad=0.4", fc="white", ec=BRANCH, alpha=0.85, lw=0.8))
ax.annotate(str(am_year) + "年 " + am_date,
xy=(am_year, am_doy), xytext=(am_year+90, am_doy-9),
fontsize=11, color=BRANCH, fontweight="bold", alpha=0.9,
arrowprops=dict(arrowstyle="->", connectionstyle="arc3,rad=-0.25",
color=BRANCH, lw=1.2, alpha=0.9),
bbox=dict(boxstyle="round,pad=0.4", fc="white", ec=BRANCH, alpha=0.85, lw=0.8))
yticks = [85, 95, 105, 115, 125]
ax.set_yticks(yticks)
ax.set_yticklabels([doy_to_date(d) for d in yticks])
ax.set_xlabel("年 (AD)", fontsize=12, color=BRANCH)
ax.set_ylabel("満開日", fontsize=12, color=BRANCH)
ax.set_title("直近 30 年と全期間の比較",
loc="left", pad=15, fontsize=15, color=BRANCH, fontweight="black")
ax.set_xlim(800, 2025)
ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.12), ncol=1, frameon=False, fontsize=11)
ax.invert_yaxis()
ax.grid(alpha=0.15, color=BRANCH)
ax.spines[["top", "right"]].set_visible(False)
ax.spines[["bottom", "left"]].set_color(BRANCH)
ax.tick_params(colors=BRANCH)
plt.tight_layout()
plt.show()
おわりに
本記事で使った fudebako は、ブラウザ上で Python コードを完結できる環境です。CSV のドラッグ&ドロップによる取り込み、標準で通る日本語表示、micropip を介した seaborn など追加パッケージのロードに対応しており、ローカル環境を汚さずに分析や記事執筆に使えます。
参考文献
- 青野靖之・川合慶子 (2008). "Phenological data series of cherry tree flowering in Kyoto, Japan, and its application to reconstruction of springtime temperatures since the 9th century"(京都における桜の満開日の系列データと、9 世紀以降の春期気温復元への応用). International Journal of Climatology, 28, 905-914. doi: 10.1002/joc.1594
- Reddit r/dataisbeautiful の話題投稿(u/JoshOlDorr, 2021): For 1200 years, the date that cherry blossoms have fully opened has been tracked
データセットは大阪公立大学(旧大阪府立大学)青野研究室で長らく公開されていましたが、現在はサイトが閉鎖されています。本記事では bmait101/data-viz のミラー版を使用しました。
fudebako は GitHub jugoya-ai/fudebako で公開しています。
動画化・転載歓迎
本記事の内容を YouTube 動画 / Podcast / 社内資料 等に転載される際は、自由にお使いください。




