1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

京都の桜 1212 年分のデータを seaborn / matplotlib で可視化する

1
Last updated at Posted at 2026-04-29

京都の桜の満開日を 1212 年分にわたって記録したデータがあります。

このデータは、大阪公立大学(旧大阪府立大学)の 青野靖之 教授と 川合慶子 氏が、平安時代の『日本後紀』(812 年)から現代までの史料を読み解き、年ごとの満開日として整理したものです(Aono & Kazui, International Journal of Climatology, 2008)。

このデータが 2021 年、「r/dataisbeautiful」に投稿されて 15,000 upvote を超える反応を集めました。投稿者の散布図と移動平均線が、文字通り「桜の枝」のように見えたのも大きな理由です。

このデータを題材に、seabornmatplotlib で美しいグラフを描く実践例をまとめます。配色、マーカー形状、注釈の配置、移動平均、Warming Stripes、ヒストグラムの重ね合わせなど、他の題材にも転用しやすい手法を中心に扱います。

本稿で用いるのはブラウザ上で動く Python 環境 fudebako です。インストール不要、データはローカル完結。matplotlib の日本語ラベルが標準で表示されます。

データの読み込み

データは bmait101/data-viz の KyotoFullFlowerW.xls(青野・川合 2008 のオリジナル Excel のミラー)を CSV 化し、GitHub Gist で公開 しました。

ブラウザ上の Python は標準ライブラリの urllibhttps を扱えないため、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()

section1 v4 hero

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()

section2 v3 histogram

中央値の縦点線が、約 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()

section3 v3 violin

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()

section4 v4 warming stripes

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()

section5 v4 anomaly

おわりに

本記事で使った 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 / 社内資料 等に転載される際は、自由にお使いください。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?